diff --git a/js/src/menu.js b/js/src/menu.js index 0dd7efd396f5..31d2c8e976a0 100644 --- a/js/src/menu.js +++ b/js/src/menu.js @@ -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) { @@ -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 @@ -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. @@ -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 diff --git a/js/tests/unit/menu.spec.js b/js/tests/unit/menu.spec.js index 74edcaf20d65..f23f5c9fa36e 100644 --- a/js/tests/unit/menu.spec.js +++ b/js/tests/unit/menu.spec.js @@ -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 = [ + '
', + ' ', + ' ', + '
' + ].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 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 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 = [