Skip to content

Commit 5026539

Browse files
committed
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.
1 parent fb6aa30 commit 5026539

2 files changed

Lines changed: 71 additions & 5 deletions

File tree

js/src/menu.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,29 @@ class Menu extends BaseComponent {
950950
}
951951
}
952952

953+
static _getToggleFromKeydownContext(element, event) {
954+
if (element.matches?.(SELECTOR_DATA_TOGGLE)) {
955+
return element
956+
}
957+
958+
// Root panel is usually a sibling of the toggle (when not moved to a container).
959+
const siblingToggle = SelectorEngine.prev(element, SELECTOR_DATA_TOGGLE)[0] ||
960+
SelectorEngine.next(element, SELECTOR_DATA_TOGGLE)[0]
961+
if (siblingToggle) {
962+
return siblingToggle
963+
}
964+
965+
// Nested submenu panels (and menus moved to `container`) are not siblings of
966+
// the toggle. Resolve the owning open Menu instance from the event target.
967+
for (const instance of Menu._openInstances) {
968+
if (instance._menu?.contains(event.target) || instance._element === event.target) {
969+
return instance._element
970+
}
971+
}
972+
973+
return SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode)
974+
}
975+
953976
static dataApiKeydownHandler(event) {
954977
// Treat contenteditable hosts (e.g. rich-text editors) like inputs so the
955978
// menu doesn't hijack their arrow keys.
@@ -971,11 +994,7 @@ class Menu extends BaseComponent {
971994
return
972995
}
973996

974-
const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?
975-
this :
976-
(SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] ||
977-
SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||
978-
SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode))
997+
const getToggleButton = Menu._getToggleFromKeydownContext(this, event)
979998

980999
if (!getToggleButton) {
9811000
return

js/tests/unit/menu.spec.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2907,6 +2907,53 @@ describe('Menu', () => {
29072907
})
29082908
})
29092909

2910+
it('should arrow-navigate items inside an open nested submenu', () => {
2911+
return new Promise(resolve => {
2912+
fixtureEl.innerHTML = [
2913+
'<div>',
2914+
' <button class="btn" data-bs-toggle="menu">Menu</button>',
2915+
' <div class="menu">',
2916+
' <div class="submenu" id="submenu1">',
2917+
' <button class="menu-item" type="button">Submenu 1</button>',
2918+
' <div class="menu" id="nestedMenu">',
2919+
' <a id="nested1" class="menu-item" href="#">Action 1</a>',
2920+
' <a id="nested2" class="menu-item" href="#">Action 2</a>',
2921+
' </div>',
2922+
' </div>',
2923+
' </div>',
2924+
'</div>'
2925+
].join('')
2926+
2927+
const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
2928+
const submenuTrigger = fixtureEl.querySelector('#submenu1 > .menu-item')
2929+
const nested1 = fixtureEl.querySelector('#nested1')
2930+
const nested2 = fixtureEl.querySelector('#nested2')
2931+
const nestedMenu = fixtureEl.querySelector('#nestedMenu')
2932+
2933+
btnMenu.addEventListener('shown.bs.menu', () => {
2934+
submenuTrigger.click()
2935+
expect(nestedMenu.classList.contains('show')).toBeTrue()
2936+
2937+
nested1.focus()
2938+
const keydown = createEvent('keydown', { bubbles: true })
2939+
keydown.key = 'ArrowDown'
2940+
nested1.dispatchEvent(keydown)
2941+
2942+
expect(document.activeElement).toEqual(nested2)
2943+
2944+
const keyup = createEvent('keydown', { bubbles: true })
2945+
keyup.key = 'ArrowUp'
2946+
nested2.dispatchEvent(keyup)
2947+
expect(document.activeElement).toEqual(nested1)
2948+
resolve()
2949+
})
2950+
2951+
// eslint-disable-next-line no-new
2952+
new Menu(btnMenu)
2953+
btnMenu.click()
2954+
})
2955+
})
2956+
29102957
it('should open submenu with ArrowRight key', () => {
29112958
return new Promise(resolve => {
29122959
fixtureEl.innerHTML = [

0 commit comments

Comments
 (0)