From 3d569f807bcd6f2223cbf52d1635df4d2ba8bde3 Mon Sep 17 00:00:00 2001
From: Aljo Joby <141745680+aljojoby9@users.noreply.github.com>
Date: Fri, 17 Jul 2026 07:05:54 +0530
Subject: [PATCH 1/3] Fix v6 menu keyboard navigation for nested submenus
Include submenu triggers when collecting keyboard-navigable items so
arrow keys work with the supported .menu > .submenu > .menu-item markup.
When focus moves to another item at the same level, close open sibling
submenus that no longer relate to focus (aligned with click/hover behavior).
---
js/src/menu.js | 40 ++++++++++++++--
js/tests/unit/menu.spec.js | 97 ++++++++++++++++++++++++++++++++++++++
2 files changed, 132 insertions(+), 5 deletions(-)
diff --git a/js/src/menu.js b/js/src/menu.js
index 0dd7efd396f5..ea186c777d11 100644
--- a/js/src/menu.js
+++ b/js/src/menu.js
@@ -784,16 +784,46 @@ class Menu extends BaseComponent {
// Keyboard navigation
// -------------------------------------------------------------------------
+ _getItemsInMenu(menu) {
+ // Items may be direct children of `.menu`, or submenu triggers nested as
+ // `.menu > .submenu > .menu-item` (see menu-submenu visual tests / docs).
+ return SelectorEngine.find(
+ `:scope > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_SUBMENU_TOGGLE}`,
+ menu
+ ).filter(element => isVisible(element))
+ }
+
_selectMenuItem({ key, target }) {
const currentMenu = target.closest(SELECTOR_MENU) || this._menu
- const items = SelectorEngine.find(`:scope > ${SELECTOR_VISIBLE_ITEMS}`, currentMenu)
- .filter(element => isVisible(element))
+ const items = this._getItemsInMenu(currentMenu)
if (!items.length) {
return
}
- getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
+ const nextItem = getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target))
+ nextItem.focus()
+
+ // Arrowing between items at this level must close open sibling submenus that
+ // no longer relate to focus (mouse/click already do this via _closeSiblingSubmenus).
+ this._closeUnrelatedSubmenus(currentMenu, nextItem)
+ }
+
+ _closeUnrelatedSubmenus(currentMenu, focusedElement) {
+ for (const [submenu] of this._openSubmenus) {
+ const submenuWrapper = submenu.closest(SELECTOR_SUBMENU)
+ // Only consider submenus that are direct children of the menu being navigated
+ if (!submenuWrapper || submenuWrapper.parentElement !== currentMenu) {
+ continue
+ }
+
+ // Keep open when focus is on that submenu's trigger or still inside it
+ if (submenuWrapper.contains(focusedElement)) {
+ continue
+ }
+
+ this._closeSubmenu(submenu, submenuWrapper)
+ }
}
_handleSubmenuKeydown(event) {
@@ -867,12 +897,12 @@ class Menu extends BaseComponent {
event.stopPropagation()
const currentMenu = target.closest(SELECTOR_MENU)
- const items = SelectorEngine.find(`:scope > ${SELECTOR_VISIBLE_ITEMS}`, currentMenu)
- .filter(element => isVisible(element))
+ const items = this._getItemsInMenu(currentMenu)
if (items.length) {
const targetItem = key === HOME_KEY ? items[0] : items.at(-1)
targetItem.focus()
+ this._closeUnrelatedSubmenus(currentMenu, targetItem)
}
return true
diff --git a/js/tests/unit/menu.spec.js b/js/tests/unit/menu.spec.js
index 74edcaf20d65..9d6c0e68bb97 100644
--- a/js/tests/unit/menu.spec.js
+++ b/js/tests/unit/menu.spec.js
@@ -2765,6 +2765,103 @@ describe('Menu', () => {
})
})
+ it('should close open sibling submenu when keyboard focus moves to another item', () => {
+ return new Promise(resolve => {
+ // Match the supported markup from visual/menu-submenu.html
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+ const submenu1Wrapper = fixtureEl.querySelector('#submenu1')
+ const submenu2Wrapper = fixtureEl.querySelector('#submenu2')
+ const submenu1Trigger = submenu1Wrapper.querySelector(':scope > .menu-item')
+ const submenu2Trigger = submenu2Wrapper.querySelector(':scope > .menu-item')
+ const submenu1 = submenu1Wrapper.querySelector('.menu')
+ const submenu2 = submenu2Wrapper.querySelector('.menu')
+
+ btnMenu.addEventListener('shown.bs.menu', () => {
+ // Open first submenu via click (same as mouse path)
+ submenu1Trigger.click()
+ expect(submenu1.classList.contains('show')).toBeTrue()
+
+ // Move keyboard focus to the other sibling submenu trigger
+ submenu1Trigger.focus()
+ const keydown = createEvent('keydown', { bubbles: true })
+ keydown.key = 'ArrowDown'
+ submenu1Trigger.dispatchEvent(keydown)
+
+ expect(document.activeElement).toEqual(submenu2Trigger)
+ expect(submenu1.classList.contains('show')).toBeFalse()
+ expect(submenu2.classList.contains('show')).toBeFalse()
+ resolve()
+ })
+
+ // eslint-disable-next-line no-new
+ new Menu(btnMenu)
+ btnMenu.click()
+ })
+ })
+
+ it('should keyboard-navigate between submenu triggers at the top level', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+ const submenu1Trigger = fixtureEl.querySelector('#submenu1 > .menu-item')
+ const plainItem = fixtureEl.querySelector('#plainItem')
+ const submenu2Trigger = fixtureEl.querySelector('#submenu2 > .menu-item')
+
+ btnMenu.addEventListener('shown.bs.menu', () => {
+ submenu1Trigger.focus()
+
+ const keydown = createEvent('keydown', { bubbles: true })
+ keydown.key = 'ArrowDown'
+ submenu1Trigger.dispatchEvent(keydown)
+ expect(document.activeElement).toEqual(plainItem)
+
+ plainItem.dispatchEvent(keydown)
+ expect(document.activeElement).toEqual(submenu2Trigger)
+ resolve()
+ })
+
+ // eslint-disable-next-line no-new
+ new Menu(btnMenu)
+ btnMenu.click()
+ })
+ })
+
it('should open submenu with ArrowRight key', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
From 466fb01a1f5c20b642b38d5e525b7f75995b909b Mon Sep 17 00:00:00 2001
From: Aljo Joby <141745680+aljojoby9@users.noreply.github.com>
Date: Fri, 17 Jul 2026 07:27:44 +0530
Subject: [PATCH 2/3] Skip disabled submenu triggers in keyboard navigation
Apply the same :not(.disabled):not(:disabled) filter used for direct
menu items when collecting submenu triggers, so arrow keys do not land
on disabled nested menu entries.
---
js/src/menu.js | 3 ++-
js/tests/unit/menu.spec.js | 45 ++++++++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/js/src/menu.js b/js/src/menu.js
index ea186c777d11..116ae8afe071 100644
--- a/js/src/menu.js
+++ b/js/src/menu.js
@@ -787,8 +787,9 @@ class Menu extends BaseComponent {
_getItemsInMenu(menu) {
// Items may be direct children of `.menu`, or submenu triggers nested as
// `.menu > .submenu > .menu-item` (see menu-submenu visual tests / docs).
+ // Always use SELECTOR_VISIBLE_ITEMS so disabled triggers are skipped too.
return SelectorEngine.find(
- `:scope > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_SUBMENU_TOGGLE}`,
+ `:scope > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_SUBMENU} > ${SELECTOR_VISIBLE_ITEMS}`,
menu
).filter(element => isVisible(element))
}
diff --git a/js/tests/unit/menu.spec.js b/js/tests/unit/menu.spec.js
index 9d6c0e68bb97..2f280b601d23 100644
--- a/js/tests/unit/menu.spec.js
+++ b/js/tests/unit/menu.spec.js
@@ -2862,6 +2862,51 @@ describe('Menu', () => {
})
})
+ it('should skip disabled submenu triggers during keyboard navigation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+ const submenu1Trigger = fixtureEl.querySelector('#submenu1 > .menu-item')
+ const submenu2Trigger = fixtureEl.querySelector('#submenu2 > .menu-item')
+ const disabledTrigger = fixtureEl.querySelector('#submenuDisabled > .menu-item')
+
+ btnMenu.addEventListener('shown.bs.menu', () => {
+ submenu1Trigger.focus()
+
+ const keydown = createEvent('keydown', { bubbles: true })
+ keydown.key = 'ArrowDown'
+ submenu1Trigger.dispatchEvent(keydown)
+
+ expect(document.activeElement).toEqual(submenu2Trigger)
+ expect(document.activeElement).not.toEqual(disabledTrigger)
+ resolve()
+ })
+
+ // eslint-disable-next-line no-new
+ new Menu(btnMenu)
+ btnMenu.click()
+ })
+ })
+
it('should open submenu with ArrowRight key', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
From 7f7143cb697a4150ed451fa7fbdbb67e4053bda8 Mon Sep 17 00:00:00 2001
From: Aljo Joby <141745680+aljojoby9@users.noreply.github.com>
Date: Fri, 17 Jul 2026 20:57:18 +0530
Subject: [PATCH 3/3] Fix arrow-key navigation inside nested submenu panels
Keydown handlers on nested .menu panels could not resolve the owning
Menu toggle (they only looked for sibling toggles), so arrow keys did
nothing inside open submenus. Resolve the instance from open menus that
contain the event target, and add a regression test.
---
js/src/menu.js | 29 +++++++++++++++++++----
js/tests/unit/menu.spec.js | 47 ++++++++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/js/src/menu.js b/js/src/menu.js
index 116ae8afe071..31d2c8e976a0 100644
--- a/js/src/menu.js
+++ b/js/src/menu.js
@@ -950,6 +950,29 @@ class Menu extends BaseComponent {
}
}
+ static _getToggleFromKeydownContext(element, event) {
+ if (element.matches?.(SELECTOR_DATA_TOGGLE)) {
+ return element
+ }
+
+ // Root panel is usually a sibling of the toggle (when not moved to a container).
+ const siblingToggle = SelectorEngine.prev(element, SELECTOR_DATA_TOGGLE)[0] ||
+ SelectorEngine.next(element, SELECTOR_DATA_TOGGLE)[0]
+ if (siblingToggle) {
+ return siblingToggle
+ }
+
+ // Nested submenu panels (and menus moved to `container`) are not siblings of
+ // the toggle. Resolve the owning open Menu instance from the event target.
+ for (const instance of Menu._openInstances) {
+ if (instance._menu?.contains(event.target) || instance._element === event.target) {
+ return instance._element
+ }
+ }
+
+ return SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode)
+ }
+
static dataApiKeydownHandler(event) {
// Treat contenteditable hosts (e.g. rich-text editors) like inputs so the
// menu doesn't hijack their arrow keys.
@@ -971,11 +994,7 @@ class Menu extends BaseComponent {
return
}
- const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?
- this :
- (SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] ||
- SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||
- SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode))
+ const getToggleButton = Menu._getToggleFromKeydownContext(this, event)
if (!getToggleButton) {
return
diff --git a/js/tests/unit/menu.spec.js b/js/tests/unit/menu.spec.js
index 2f280b601d23..f23f5c9fa36e 100644
--- a/js/tests/unit/menu.spec.js
+++ b/js/tests/unit/menu.spec.js
@@ -2907,6 +2907,53 @@ describe('Menu', () => {
})
})
+ it('should arrow-navigate items inside an open nested submenu', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+ const submenuTrigger = fixtureEl.querySelector('#submenu1 > .menu-item')
+ const nested1 = fixtureEl.querySelector('#nested1')
+ const nested2 = fixtureEl.querySelector('#nested2')
+ const nestedMenu = fixtureEl.querySelector('#nestedMenu')
+
+ btnMenu.addEventListener('shown.bs.menu', () => {
+ submenuTrigger.click()
+ expect(nestedMenu.classList.contains('show')).toBeTrue()
+
+ nested1.focus()
+ const keydown = createEvent('keydown', { bubbles: true })
+ keydown.key = 'ArrowDown'
+ nested1.dispatchEvent(keydown)
+
+ expect(document.activeElement).toEqual(nested2)
+
+ const keyup = createEvent('keydown', { bubbles: true })
+ keyup.key = 'ArrowUp'
+ nested2.dispatchEvent(keyup)
+ expect(document.activeElement).toEqual(nested1)
+ resolve()
+ })
+
+ // eslint-disable-next-line no-new
+ new Menu(btnMenu)
+ btnMenu.click()
+ })
+ })
+
it('should open submenu with ArrowRight key', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [