Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions js/src/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -784,16 +784,47 @@ 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).
// Always use SELECTOR_VISIBLE_ITEMS so disabled triggers are skipped too.
return SelectorEngine.find(
`:scope > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_SUBMENU} > ${SELECTOR_VISIBLE_ITEMS}`,
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) {
Expand Down Expand Up @@ -867,12 +898,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
Expand Down Expand Up @@ -919,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.
Expand All @@ -940,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
Expand Down
189 changes: 189 additions & 0 deletions js/tests/unit/menu.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2765,6 +2765,195 @@ 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 = [
'<div>',
' <button class="btn" data-bs-toggle="menu">Menu</button>',
' <div class="menu">',
' <div class="submenu" id="submenu1">',
' <button class="menu-item" type="button">Submenu 1</button>',
' <div class="menu">',
' <a class="menu-item" href="#">Action 1</a>',
' </div>',
' </div>',
' <div class="submenu" id="submenu2">',
' <button class="menu-item" type="button">Submenu 2</button>',
' <div class="menu">',
' <a class="menu-item" href="#">Action 2</a>',
' </div>',
' </div>',
' </div>',
'</div>'
].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 = [
'<div>',
' <button class="btn" data-bs-toggle="menu">Menu</button>',
' <div class="menu">',
' <div class="submenu" id="submenu1">',
' <button class="menu-item" type="button">Submenu 1</button>',
' <div class="menu"><a class="menu-item" href="#">Action 1</a></div>',
' </div>',
' <a id="plainItem" class="menu-item" href="#">Plain item</a>',
' <div class="submenu" id="submenu2">',
' <button class="menu-item" type="button">Submenu 2</button>',
' <div class="menu"><a class="menu-item" href="#">Action 2</a></div>',
' </div>',
' </div>',
'</div>'
].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 skip disabled submenu triggers during keyboard navigation', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<div>',
' <button class="btn" data-bs-toggle="menu">Menu</button>',
' <div class="menu">',
' <div class="submenu" id="submenu1">',
' <button class="menu-item" type="button">Submenu 1</button>',
' <div class="menu"><a class="menu-item" href="#">Action 1</a></div>',
' </div>',
' <div class="submenu" id="submenuDisabled">',
' <button class="menu-item" type="button" disabled>Disabled submenu</button>',
' <div class="menu"><a class="menu-item" href="#">Hidden</a></div>',
' </div>',
' <div class="submenu" id="submenu2">',
' <button class="menu-item" type="button">Submenu 2</button>',
' <div class="menu"><a class="menu-item" href="#">Action 2</a></div>',
' </div>',
' </div>',
'</div>'
].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 arrow-navigate items inside an open nested submenu', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<div>',
' <button class="btn" data-bs-toggle="menu">Menu</button>',
' <div class="menu">',
' <div class="submenu" id="submenu1">',
' <button class="menu-item" type="button">Submenu 1</button>',
' <div class="menu" id="nestedMenu">',
' <a id="nested1" class="menu-item" href="#">Action 1</a>',
' <a id="nested2" class="menu-item" href="#">Action 2</a>',
' </div>',
' </div>',
' </div>',
'</div>'
].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 = [
Expand Down