From b7759659283782749b7860f95c2d584a6edd9a09 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Gueriaud <51313578+jcgueriaud1@users.noreply.github.com> Date: Mon, 12 Aug 2024 11:52:05 +0300 Subject: [PATCH] feat: add tabNavigation option for using menu-bar as button group (#7525) --- .../menu-bar/src/vaadin-menu-bar-mixin.d.ts | 7 +++ .../menu-bar/src/vaadin-menu-bar-mixin.js | 62 +++++++++++++++++-- packages/menu-bar/test/a11y.common.js | 37 +++++++++++ .../dom/__snapshots__/menu-bar.test.snap.js | 6 +- packages/menu-bar/test/menu-bar.common.js | 59 ++++++++++++++++++ packages/menu-bar/test/overflow.common.js | 16 +++++ packages/menu-bar/test/sub-menu.common.js | 35 +++++++++++ .../menu-bar/test/typings/menu-bar.types.ts | 1 + 8 files changed, 216 insertions(+), 7 deletions(-) diff --git a/packages/menu-bar/src/vaadin-menu-bar-mixin.d.ts b/packages/menu-bar/src/vaadin-menu-bar-mixin.d.ts index a3606da846..2a90840d07 100644 --- a/packages/menu-bar/src/vaadin-menu-bar-mixin.d.ts +++ b/packages/menu-bar/src/vaadin-menu-bar-mixin.d.ts @@ -145,6 +145,13 @@ export declare class MenuBarMixinClass { */ reverseCollapse: boolean | null | undefined; + /** + * If true, the top-level menu items is traversable by tab + * instead of arrow keys (i.e. disabling roving tabindex) + * @attr {boolean} tab-navigation + */ + tabNavigation: boolean | null | undefined; + /** * Closes the current submenu. */ diff --git a/packages/menu-bar/src/vaadin-menu-bar-mixin.js b/packages/menu-bar/src/vaadin-menu-bar-mixin.js index 76154b503b..8e492760db 100644 --- a/packages/menu-bar/src/vaadin-menu-bar-mixin.js +++ b/packages/menu-bar/src/vaadin-menu-bar-mixin.js @@ -5,7 +5,7 @@ */ import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js'; import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js'; -import { isElementFocused, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js'; +import { isElementFocused, isElementHidden, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js'; import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js'; import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; @@ -144,6 +144,15 @@ export const MenuBarMixin = (superClass) => type: Boolean, }, + /** + * If true, the top-level menu items is traversable by tab + * instead of arrow keys (i.e. disabling roving tabindex) + * @attr {boolean} tab-navigation + */ + tabNavigation: { + type: Boolean, + }, + /** * @type {boolean} * @protected @@ -173,6 +182,7 @@ export const MenuBarMixin = (superClass) => '__i18nChanged(i18n, _overflow)', '_menuItemsChanged(items, _overflow, _container)', '_reverseCollapseChanged(reverseCollapse, _overflow, _container)', + '_tabNavigationChanged(tabNavigation, _overflow, _container)', ]; } @@ -349,6 +359,22 @@ export const MenuBarMixin = (superClass) => } } + /** @private */ + _tabNavigationChanged(tabNavigation, overflow, container) { + if (overflow && container) { + const target = this.querySelector('[tabindex="0"]'); + this._buttons.forEach((btn) => { + if (target) { + this._setTabindex(btn, btn === target); + } else { + this._setTabindex(btn, false); + } + btn.setAttribute('role', tabNavigation ? 'button' : 'menuitem'); + }); + } + this.setAttribute('role', tabNavigation ? 'group' : 'menubar'); + } + /** @private */ __hasOverflowChanged(hasOverflow, overflow) { if (overflow) { @@ -540,7 +566,7 @@ export const MenuBarMixin = (superClass) => /** @protected */ _initButtonAttrs(button) { - button.setAttribute('role', 'menuitem'); + button.setAttribute('role', this.tabNavigation ? 'button' : 'menuitem'); if (button === this._overflow || (button.item && button.item.children)) { button.setAttribute('aria-haspopup', 'true'); @@ -667,7 +693,11 @@ export const MenuBarMixin = (superClass) => /** @protected */ _setTabindex(button, focused) { - button.setAttribute('tabindex', focused ? '0' : '-1'); + if (this.tabNavigation && !button.disabled) { + button.setAttribute('tabindex', '0'); + } else { + button.setAttribute('tabindex', focused ? '0' : '-1'); + } } /** @@ -715,7 +745,12 @@ export const MenuBarMixin = (superClass) => */ _setFocused(focused) { if (focused) { - const target = this.querySelector('[tabindex="0"]'); + let target = this.querySelector('[tabindex="0"]'); + if (this.tabNavigation) { + // Switch submenu on menu button Tab / Shift Tab + target = this.querySelector('[focused]'); + this.__switchSubMenu(target); + } if (target) { this._buttons.forEach((btn) => { this._setTabindex(btn, btn === target); @@ -839,6 +874,25 @@ export const MenuBarMixin = (superClass) => // Prevent ArrowLeft from being handled in context-menu e.stopImmediatePropagation(); this._onKeyDown(e); + } else if (e.keyCode === 9 && this.tabNavigation) { + // Switch opened submenu on submenu item Tab / Shift Tab + const items = this._getItems() || []; + const currentIdx = items.indexOf(this.focused); + const increment = e.shiftKey ? -1 : 1; + let idx = currentIdx + increment; + idx = this._getAvailableIndex(items, idx, increment, (item) => !isElementHidden(item)); + this.__switchSubMenu(items[idx]); + } + } + } + + /** @private */ + __switchSubMenu(target) { + const wasExpanded = this._expandedButton != null && this._expandedButton !== target; + if (wasExpanded) { + this._close(); + if (target.item && target.item.children) { + this.__openSubMenu(target, true, { keepFocus: true }); } } } diff --git a/packages/menu-bar/test/a11y.common.js b/packages/menu-bar/test/a11y.common.js index 05f4af74cc..16dc840557 100644 --- a/packages/menu-bar/test/a11y.common.js +++ b/packages/menu-bar/test/a11y.common.js @@ -35,6 +35,43 @@ describe('a11y', () => { }); }); + it('should set role attribute on host element in tabNavigation', async () => { + menu.tabNavigation = true; + await nextRender(menu); + expect(menu.getAttribute('role')).to.equal('group'); + }); + + it('should set role attribute on menu bar buttons in tabNavigation', async () => { + menu.tabNavigation = true; + await nextRender(menu); + buttons.forEach((btn) => { + expect(btn.getAttribute('role')).to.equal('button'); + }); + }); + + it('should update role attribute on menu bar buttons when changing items', async () => { + menu.items = [...menu.items, { text: 'New item' }]; + await nextRender(menu); + menu._buttons.forEach((btn) => { + expect(btn.getAttribute('role')).to.equal('menuitem'); + }); + }); + + it('should update role attribute on menu bar buttons when changing items in tabNavigation', async () => { + menu.tabNavigation = true; + await nextRender(menu); + menu.items = [...menu.items, { text: 'New item' }]; + await nextRender(menu); + menu._buttons.forEach((btn) => { + expect(btn.getAttribute('role')).to.equal('button'); + }); + menu.tabNavigation = false; + await nextRender(menu); + menu._buttons.forEach((btn) => { + expect(btn.getAttribute('role')).to.equal('menuitem'); + }); + }); + it('should set aria-haspopup attribute on buttons with nested items', () => { buttons.forEach((btn) => { const hasPopup = btn === overflow || btn.item.children ? 'true' : null; diff --git a/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js b/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js index df80685f07..c949d5c55e 100644 --- a/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js +++ b/packages/menu-bar/test/dom/__snapshots__/menu-bar.test.snap.js @@ -15,7 +15,7 @@ snapshots["menu-bar basic"] = aria-expanded="false" aria-haspopup="true" role="menuitem" - tabindex="0" + tabindex="-1" > Reports @@ -31,7 +31,7 @@ snapshots["menu-bar basic"] = class="help" last-visible="" role="menuitem" - tabindex="0" + tabindex="-1" > @@ -46,7 +46,7 @@ snapshots["menu-bar basic"] = hidden="" role="menuitem" slot="overflow" - tabindex="0" + tabindex="-1" >