diff --git a/packages/a11y-base/src/keyboard-direction-mixin.d.ts b/packages/a11y-base/src/keyboard-direction-mixin.d.ts index 5a5ab71c4c..bcbf6ef00c 100644 --- a/packages/a11y-base/src/keyboard-direction-mixin.d.ts +++ b/packages/a11y-base/src/keyboard-direction-mixin.d.ts @@ -38,4 +38,10 @@ export declare class KeyboardDirectionMixinClass { * Focus the given item. Override this method to add custom logic. */ protected _focusItem(item: Element, navigating: boolean): void; + + /** + * Returns whether the item is focusable. By default, + * returns true if the item is not disabled. + */ + protected _isItemFocusable(item: Element): boolean; } diff --git a/packages/a11y-base/src/keyboard-direction-mixin.js b/packages/a11y-base/src/keyboard-direction-mixin.js index 5b36382da5..4acbecb52a 100644 --- a/packages/a11y-base/src/keyboard-direction-mixin.js +++ b/packages/a11y-base/src/keyboard-direction-mixin.js @@ -171,7 +171,7 @@ export const KeyboardDirectionMixin = (superclass) => const item = items[idx]; - if (!item.hasAttribute('disabled') && this.__isMatchingItem(item, condition)) { + if (this._isItemFocusable(item) && this.__isMatchingItem(item, condition)) { return idx; } } @@ -189,4 +189,16 @@ export const KeyboardDirectionMixin = (superclass) => __isMatchingItem(item, condition) { return typeof condition === 'function' ? condition(item) : true; } + + /** + * Returns whether the item is focusable. By default, + * returns true if the item is not disabled. + * + * @param {Element} item + * @return {boolean} + * @protected + */ + _isItemFocusable(item) { + return !item.hasAttribute('disabled'); + } }; diff --git a/packages/button/src/vaadin-button-mixin.js b/packages/button/src/vaadin-button-mixin.js index f4ce0b9a3b..068616064e 100644 --- a/packages/button/src/vaadin-button-mixin.js +++ b/packages/button/src/vaadin-button-mixin.js @@ -92,8 +92,18 @@ export const ButtonMixin = (superClass) => /** @private */ __onInteractionEvent(event) { - if (this.disabled) { + if (this.__shouldSuppressInteractionEvent(event)) { event.stopImmediatePropagation(); } } + + /** + * Returns whether to suppress interaction events like `click`, `keydown`, etc. + * By default suppresses all interaction events when the button is disabled. + * + * @private + */ + __shouldSuppressInteractionEvent(_event) { + return this.disabled; + } }; diff --git a/packages/menu-bar/src/vaadin-lit-menu-bar-button.js b/packages/menu-bar/src/vaadin-lit-menu-bar-button.js index c892838743..035760fd1a 100644 --- a/packages/menu-bar/src/vaadin-lit-menu-bar-button.js +++ b/packages/menu-bar/src/vaadin-lit-menu-bar-button.js @@ -48,6 +48,20 @@ class MenuBarButton extends Button { super._onKeyDown(event); this.__triggeredWithActiveKeys = null; } + + /** + * Override method inherited from `ButtonMixin` to allow keyboard navigation with + * arrow keys in the menu bar when the button is focusable in the disabled state. + * + * @override + */ + __shouldSuppressInteractionEvent(event) { + if (event.type === 'keydown' && ['ArrowLeft', 'ArrowRight'].includes(event.key)) { + return false; + } + + return super.__shouldSuppressInteractionEvent(event); + } } defineCustomElement(MenuBarButton); diff --git a/packages/menu-bar/src/vaadin-menu-bar-button.js b/packages/menu-bar/src/vaadin-menu-bar-button.js index 452ce932da..38f89b36f6 100644 --- a/packages/menu-bar/src/vaadin-menu-bar-button.js +++ b/packages/menu-bar/src/vaadin-menu-bar-button.js @@ -45,6 +45,20 @@ class MenuBarButton extends Button { super._onKeyDown(event); this.__triggeredWithActiveKeys = null; } + + /** + * Override method inherited from `ButtonMixin` to allow keyboard navigation with + * arrow keys in the menu bar when the button is focusable in the disabled state. + * + * @override + */ + __shouldSuppressInteractionEvent(event) { + if (event.type === 'keydown' && ['ArrowLeft', 'ArrowRight'].includes(event.key)) { + return false; + } + + return super.__shouldSuppressInteractionEvent(event); + } } defineCustomElement(MenuBarButton); 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 f3b7733ba9..2b878936aa 100644 --- a/packages/menu-bar/src/vaadin-menu-bar-mixin.d.ts +++ b/packages/menu-bar/src/vaadin-menu-bar-mixin.d.ts @@ -99,6 +99,26 @@ export declare class MenuBarMixinClass { * {text: 'Help'} * ]; * ``` + * + * #### Disabled buttons + * + * When a root-level item (menu bar button) is disabled, it prevents all user + * interactions with it, such as focusing, clicking, opening a sub-menu, etc. + * The button is also removed from tab order, which makes it unreachable via + * the keyboard navigation. + * + * While the default behavior effectively prevents accidental interactions, + * it has an accessibility drawback: screen readers skip disabled buttons + * entirely, and users can't see tooltips that might explain why the button + * is disabled. To address this, an experimental enhancement allows disabled + * menu bar buttons to receive focus and show tooltips, while still preventing + * other interactions. This feature can be enabled with the following feature + * flag: + * + * ``` + * // Set before any menu bar is attached to the DOM. + * window.Vaadin.featureFlags.accessibleDisabledButtons = true; + * ``` */ items: MenuBarItem[]; diff --git a/packages/menu-bar/src/vaadin-menu-bar-mixin.js b/packages/menu-bar/src/vaadin-menu-bar-mixin.js index d147dd0fe8..652fee7339 100644 --- a/packages/menu-bar/src/vaadin-menu-bar-mixin.js +++ b/packages/menu-bar/src/vaadin-menu-bar-mixin.js @@ -78,6 +78,26 @@ export const MenuBarMixin = (superClass) => * ]; * ``` * + * #### Disabled buttons + * + * When a root-level item (menu bar button) is disabled, it prevents all user + * interactions with it, such as focusing, clicking, opening a sub-menu, etc. + * The button is also removed from tab order, which makes it unreachable via + * the keyboard navigation. + * + * While the default behavior effectively prevents accidental interactions, + * it has an accessibility drawback: screen readers skip disabled buttons + * entirely, and users can't see tooltips that might explain why the button + * is disabled. To address this, an experimental enhancement allows disabled + * menu bar buttons to receive focus and show tooltips, while still preventing + * other interactions. This feature can be enabled with the following feature + * flag: + * + * ``` + * // Set before any menu bar is attached to the DOM. + * window.Vaadin.featureFlags.accessibleDisabledButtons = true; + * ``` + * * @type {!Array} */ items: { @@ -688,7 +708,7 @@ export const MenuBarMixin = (superClass) => /** @protected */ _setTabindex(button, focused) { - if (this.tabNavigation && !button.disabled) { + if (this.tabNavigation && this._isItemFocusable(button)) { button.setAttribute('tabindex', '0'); } else { button.setAttribute('tabindex', focused ? '0' : '-1'); @@ -907,6 +927,10 @@ export const MenuBarMixin = (superClass) => /** @private */ __openSubMenu(button, keydown, options = {}) { + if (button.disabled) { + return; + } + const subMenu = this._subMenu; const item = button.item; @@ -1030,4 +1054,20 @@ export const MenuBarMixin = (superClass) => close() { this._close(); } + + /** + * Override method inherited from `KeyboardDirectionMixin` to allow + * focusing disabled buttons that are configured so. + * + * @param {Element} button + * @protected + * @override + */ + _isItemFocusable(button) { + if (button.disabled && button.__shouldAllowFocusWhenDisabled) { + return button.__shouldAllowFocusWhenDisabled(); + } + + return super._isItemFocusable(button); + } }; diff --git a/packages/menu-bar/test/accessible-disabled-buttons-lit.test.js b/packages/menu-bar/test/accessible-disabled-buttons-lit.test.js new file mode 100644 index 0000000000..099addfb4b --- /dev/null +++ b/packages/menu-bar/test/accessible-disabled-buttons-lit.test.js @@ -0,0 +1,3 @@ +import './not-animated-styles.js'; +import '../vaadin-lit-menu-bar.js'; +import './accessible-disabled-buttons.common.js'; diff --git a/packages/menu-bar/test/accessible-disabled-buttons-polymer.test.js b/packages/menu-bar/test/accessible-disabled-buttons-polymer.test.js new file mode 100644 index 0000000000..1c7e3648bc --- /dev/null +++ b/packages/menu-bar/test/accessible-disabled-buttons-polymer.test.js @@ -0,0 +1,3 @@ +import './not-animated-styles.js'; +import '../vaadin-menu-bar.js'; +import './accessible-disabled-buttons.common.js'; diff --git a/packages/menu-bar/test/accessible-disabled-buttons.common.js b/packages/menu-bar/test/accessible-disabled-buttons.common.js new file mode 100644 index 0000000000..e264d464b2 --- /dev/null +++ b/packages/menu-bar/test/accessible-disabled-buttons.common.js @@ -0,0 +1,84 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync, middleOfNode, nextRender } from '@vaadin/testing-helpers'; +import { resetMouse, sendKeys, sendMouse } from '@web/test-runner-commands'; + +describe('accessible disabled buttons', () => { + let menuBar, buttons; + + before(() => { + window.Vaadin.featureFlags ??= {}; + window.Vaadin.featureFlags.accessibleDisabledButtons = true; + }); + + after(() => { + window.Vaadin.featureFlags.accessibleDisabledButtons = false; + }); + + beforeEach(async () => { + menuBar = fixtureSync(''); + menuBar.items = [ + { text: 'Item 0' }, + { text: 'Item 1', disabled: true, children: [{ text: 'SubItem 0' }] }, + { text: 'Item 2' }, + ]; + await nextRender(menuBar); + buttons = menuBar._buttons; + }); + + afterEach(async () => { + await resetMouse(); + }); + + it('should not open sub-menu on disabled button click', async () => { + const { x, y } = middleOfNode(buttons[1]); + await sendMouse({ type: 'click', position: [Math.floor(x), Math.floor(y)] }); + expect(buttons[1].hasAttribute('expanded')).to.be.false; + }); + + it('should not open sub-menu on disabled button hover', async () => { + menuBar.openOnHover = true; + const { x, y } = middleOfNode(buttons[1]); + await sendMouse({ type: 'move', position: [Math.floor(x), Math.floor(y)] }); + expect(buttons[1].hasAttribute('expanded')).to.be.false; + }); + + it('should include disabled buttons in arrow key navigation', async () => { + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(buttons[0]); + + await sendKeys({ press: 'ArrowRight' }); + expect(document.activeElement).to.equal(buttons[1]); + + await sendKeys({ press: 'ArrowRight' }); + expect(document.activeElement).to.equal(buttons[2]); + + await sendKeys({ press: 'ArrowLeft' }); + expect(document.activeElement).to.equal(buttons[1]); + + await sendKeys({ press: 'ArrowLeft' }); + expect(document.activeElement).to.equal(buttons[0]); + }); + + it('should include disabled buttons in Tab navigation', async () => { + menuBar.tabNavigation = true; + + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(buttons[0]); + + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(buttons[1]); + + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(buttons[2]); + + await sendKeys({ down: 'Shift' }); + await sendKeys({ press: 'Tab' }); + await sendKeys({ up: 'Shift' }); + expect(document.activeElement).to.equal(buttons[1]); + + await sendKeys({ down: 'Shift' }); + await sendKeys({ press: 'Tab' }); + await sendKeys({ up: 'Shift' }); + expect(document.activeElement).to.equal(buttons[0]); + }); +}); diff --git a/test/integration/menu-bar-tooltip.test.js b/test/integration/menu-bar-tooltip.test.js index 86ead0c7e0..929b16c808 100644 --- a/test/integration/menu-bar-tooltip.test.js +++ b/test/integration/menu-bar-tooltip.test.js @@ -7,11 +7,14 @@ import { fixtureSync, focusin, focusout, + middleOfNode, mousedown, nextRender, tabKeyDown, } from '@vaadin/testing-helpers'; +import { resetMouse, sendKeys, sendMouse } from '@web/test-runner-commands'; import sinon from 'sinon'; +import './not-animated-styles.js'; import '@vaadin/menu-bar'; import { Tooltip } from '@vaadin/tooltip'; import { mouseenter, mouseleave } from '@vaadin/tooltip/test/helpers.js'; @@ -34,316 +37,393 @@ describe('menu-bar with tooltip', () => { Tooltip.setDefaultHideDelay(0); }); - beforeEach(async () => { - menuBar = fixtureSync(` - - - - `); - menuBar.items = [ - { text: 'Edit', tooltip: 'Edit tooltip' }, - { - text: 'Share', - children: [{ text: 'By email' }], - }, - { - text: 'Move', - tooltip: 'Move tooltip', - children: [{ text: 'To folder' }], - }, - ]; - - await nextRender(); - buttons = menuBar._buttons; - - tooltip = menuBar.querySelector('vaadin-tooltip'); - }); - - it('should set manual on the tooltip to true', () => { - expect(tooltip.manual).to.be.true; - }); - - it('should show tooltip on menu button mouseover', () => { - mouseover(buttons[0]); - expect(tooltip.opened).to.be.true; - }); - - it('should use the tooltip property of an item as tooltip', () => { - mouseover(buttons[0]); - expect(getTooltipText()).to.equal('Edit tooltip'); - }); - - it('should not show tooltip for an item which has no tooltip', () => { - mouseover(buttons[1]); - expect(getTooltipText()).not.to.be.ok; - }); - - it('should not show tooltip on another parent menu button mouseover when open', async () => { - mouseover(buttons[1]); - buttons[1].click(); - await nextRender(); - mouseover(buttons[2]); - await nextRender(); - expect(tooltip.opened).to.be.false; - }); - - it('should hide tooltip on menu bar mouseleave', () => { - mouseover(buttons[0]); - mouseleave(menuBar); - expect(tooltip.opened).to.be.false; - }); - - it('should hide tooltip on menu bar container mouseover', () => { - mouseover(buttons[0]); - mouseover(menuBar._container); - expect(tooltip.opened).to.be.false; - }); - - it('should show tooltip again on menu bar button mouseover', () => { - mouseover(buttons[0]); - mouseover(menuBar._container); - mouseover(buttons[1]); - expect(tooltip.opened).to.be.true; - }); - - it('should set tooltip target on menu button mouseover', () => { - mouseover(buttons[0]); - expect(tooltip.target).to.be.equal(buttons[0]); - }); - - it('should set tooltip context on menu button mouseover', () => { - mouseover(buttons[0]); - expect(tooltip.context).to.be.instanceOf(Object); - expect(tooltip.context.item.text).to.equal('Edit'); - }); - - it('should change tooltip context on another menu button mouseover', () => { - mouseover(buttons[0]); - mouseover(buttons[1]); - expect(tooltip.context.item.text).to.equal('Share'); - }); - - it('should hide tooltip on menu button mousedown', () => { - mouseover(buttons[0]); - mousedown(buttons[0]); - expect(tooltip.opened).to.be.false; - }); - - it('should hide tooltip on mouseleave from overlay to outside', () => { - const overlay = tooltip._overlayElement; - mouseover(buttons[0]); - mouseenter(overlay); - mouseleave(overlay); - expect(overlay.opened).to.be.false; - }); - - it('should not show tooltip on focus without keyboard interaction', async () => { - buttons[0].focus(); - await nextRender(); - expect(tooltip.opened).to.be.false; - }); - - it('should show tooltip on menu button keyboard focus', () => { - tabKeyDown(document.body); - focusin(buttons[0]); - expect(tooltip.opened).to.be.true; - }); - - it('should not show tooltip on another parent menu button focus when open', async () => { - buttons[0].focus(); - arrowRight(buttons[0]); - arrowDown(buttons[1]); - arrowRight(buttons[1]); - await nextRender(); - expect(tooltip.opened).to.be.false; - }); + describe('default', () => { + beforeEach(async () => { + menuBar = fixtureSync(` + + + + `); + menuBar.items = [ + { text: 'Edit', tooltip: 'Edit tooltip' }, + { + text: 'Share', + children: [{ text: 'By email' }], + }, + { + text: 'Move', + tooltip: 'Move tooltip', + children: [{ text: 'To folder' }], + }, + ]; - it('should set tooltip target on menu button keyboard focus', () => { - tabKeyDown(document.body); - focusin(buttons[0]); - expect(tooltip.target).to.be.equal(buttons[0]); - }); + await nextRender(); + buttons = menuBar._buttons; - it('should set tooltip context on menu button keyboard focus', () => { - tabKeyDown(document.body); - focusin(buttons[0]); - expect(tooltip.context).to.be.instanceOf(Object); - expect(tooltip.context.item.text).to.equal('Edit'); - }); + tooltip = menuBar.querySelector('vaadin-tooltip'); + }); - it('should hide tooltip on menu-bar focusout', () => { - tabKeyDown(document.body); - focusin(buttons[0]); - focusout(menuBar); - expect(tooltip.opened).to.be.false; - }); + it('should set manual on the tooltip to true', () => { + expect(tooltip.manual).to.be.true; + }); - it('should hide tooltip on menuBar menu button content Esc', () => { - tabKeyDown(document.body); - focusin(buttons[0]); - escKeyDown(buttons[0]); - expect(tooltip.opened).to.be.false; - }); + it('should show tooltip on menu button mouseover', () => { + mouseover(buttons[0]); + expect(tooltip.opened).to.be.true; + }); - it('should set tooltip opened to false when the menuBar is removed', () => { - mouseover(buttons[0]); + it('should use the tooltip property of an item as tooltip', () => { + mouseover(buttons[0]); + expect(getTooltipText()).to.equal('Edit tooltip'); + }); - menuBar.remove(); + it('should not show tooltip for an item which has no tooltip', () => { + mouseover(buttons[1]); + expect(getTooltipText()).not.to.be.ok; + }); - expect(tooltip.opened).to.be.false; - }); + it('should not show tooltip on another parent menu button mouseover when open', async () => { + mouseover(buttons[1]); + buttons[1].click(); + await nextRender(); + mouseover(buttons[2]); + await nextRender(); + expect(tooltip.opened).to.be.false; + }); - it('should not set tooltip properties if there is no tooltip', async () => { - const spyTarget = sinon.spy(menuBar._tooltipController, 'setTarget'); - const spyContent = sinon.spy(menuBar._tooltipController, 'setContext'); - const spyOpened = sinon.spy(menuBar._tooltipController, 'setOpened'); + it('should hide tooltip on menu bar mouseleave', () => { + mouseover(buttons[0]); + mouseleave(menuBar); + expect(tooltip.opened).to.be.false; + }); - tooltip.remove(); - await nextRender(); + it('should hide tooltip on menu bar container mouseover', () => { + mouseover(buttons[0]); + mouseover(menuBar._container); + expect(tooltip.opened).to.be.false; + }); - mouseover(buttons[0]); + it('should show tooltip again on menu bar button mouseover', () => { + mouseover(buttons[0]); + mouseover(menuBar._container); + mouseover(buttons[1]); + expect(tooltip.opened).to.be.true; + }); - expect(spyTarget.called).to.be.false; - expect(spyContent.called).to.be.false; - expect(spyOpened.called).to.be.false; - }); + it('should set tooltip target on menu button mouseover', () => { + mouseover(buttons[0]); + expect(tooltip.target).to.be.equal(buttons[0]); + }); - it('should not override a custom generator', () => { - tooltip.generator = () => 'Custom tooltip'; - mouseover(buttons[0]); - expect(getTooltipText()).to.equal('Custom tooltip'); - }); + it('should set tooltip context on menu button mouseover', () => { + mouseover(buttons[0]); + expect(tooltip.context).to.be.instanceOf(Object); + expect(tooltip.context.item.text).to.equal('Edit'); + }); - describe('overflow button', () => { - beforeEach(async () => { - // Reduce the width by the width of the last visible button - menuBar.style.display = 'inline-block'; - menuBar.style.width = `${menuBar.offsetWidth - buttons[2].offsetWidth}px`; - await nextRender(); - await nextRender(); - // Increase the width by the width of the overflow button - menuBar.style.width = `${menuBar.offsetWidth + menuBar._overflow.offsetWidth}px`; - await nextRender(); - await nextRender(); + it('should change tooltip context on another menu button mouseover', () => { + mouseover(buttons[0]); + mouseover(buttons[1]); + expect(tooltip.context.item.text).to.equal('Share'); }); - it('should not show tooltip on overflow button mouseover', () => { - mouseover(buttons[buttons.length - 1]); + it('should hide tooltip on menu button mousedown', () => { + mouseover(buttons[0]); + mousedown(buttons[0]); expect(tooltip.opened).to.be.false; }); - it('should close tooltip on overflow button mouseover', () => { + it('should hide tooltip on mouseleave from overlay to outside', () => { + const overlay = tooltip._overlayElement; mouseover(buttons[0]); - mouseover(buttons[buttons.length - 1]); - expect(tooltip.opened).to.be.false; + mouseenter(overlay); + mouseleave(overlay); + expect(overlay.opened).to.be.false; }); - it('should close tooltip on overflow button keyboard navigation', () => { + it('should not show tooltip on focus without keyboard interaction', async () => { buttons[0].focus(); - arrowRight(buttons[0]); - expect(tooltip.opened).to.be.true; - arrowRight(buttons[1]); + await nextRender(); expect(tooltip.opened).to.be.false; }); - it('should not show tooltip on overflow button keyboard focus', () => { + it('should show tooltip on menu button keyboard focus', () => { + tabKeyDown(document.body); + focusin(buttons[0]); + expect(tooltip.opened).to.be.true; + }); + + it('should not show tooltip on another parent menu button focus when open', async () => { buttons[0].focus(); arrowRight(buttons[0]); + arrowDown(buttons[1]); arrowRight(buttons[1]); + await nextRender(); + expect(tooltip.opened).to.be.false; + }); + + it('should set tooltip target on menu button keyboard focus', () => { + tabKeyDown(document.body); + focusin(buttons[0]); + expect(tooltip.target).to.be.equal(buttons[0]); + }); + + it('should set tooltip context on menu button keyboard focus', () => { tabKeyDown(document.body); - focusin(buttons[buttons.length - 1]); + focusin(buttons[0]); + expect(tooltip.context).to.be.instanceOf(Object); + expect(tooltip.context.item.text).to.equal('Edit'); + }); + + it('should hide tooltip on menu-bar focusout', () => { + tabKeyDown(document.body); + focusin(buttons[0]); + focusout(menuBar); expect(tooltip.opened).to.be.false; }); - }); - describe('open on hover', () => { - beforeEach(() => { - menuBar.openOnHover = true; + it('should hide tooltip on menuBar menu button content Esc', () => { + tabKeyDown(document.body); + focusin(buttons[0]); + escKeyDown(buttons[0]); + expect(tooltip.opened).to.be.false; }); - it('should show tooltip on mouseover for button without children', () => { - menuBar.openOnHover = true; + it('should set tooltip opened to false when the menuBar is removed', () => { mouseover(buttons[0]); - expect(tooltip.opened).to.be.true; - }); - it('should not show tooltip on mouseover for button with children', () => { - menuBar.openOnHover = true; - mouseover(buttons[1]); + menuBar.remove(); + expect(tooltip.opened).to.be.false; }); - }); - describe('delay', () => { - const DEFAULT_DELAY = 500; + it('should not set tooltip properties if there is no tooltip', async () => { + const spyTarget = sinon.spy(menuBar._tooltipController, 'setTarget'); + const spyContent = sinon.spy(menuBar._tooltipController, 'setContext'); + const spyOpened = sinon.spy(menuBar._tooltipController, 'setOpened'); + + tooltip.remove(); + await nextRender(); + + mouseover(buttons[0]); + + expect(spyTarget.called).to.be.false; + expect(spyContent.called).to.be.false; + expect(spyOpened.called).to.be.false; + }); + + it('should not override a custom generator', () => { + tooltip.generator = () => 'Custom tooltip'; + mouseover(buttons[0]); + expect(getTooltipText()).to.equal('Custom tooltip'); + }); + + describe('overflow button', () => { + beforeEach(async () => { + // Reduce the width by the width of the last visible button + menuBar.style.display = 'inline-block'; + menuBar.style.width = `${menuBar.offsetWidth - buttons[2].offsetWidth}px`; + await nextRender(); + await nextRender(); + // Increase the width by the width of the overflow button + menuBar.style.width = `${menuBar.offsetWidth + menuBar._overflow.offsetWidth}px`; + await nextRender(); + await nextRender(); + }); + + it('should not show tooltip on overflow button mouseover', () => { + mouseover(buttons[buttons.length - 1]); + expect(tooltip.opened).to.be.false; + }); - let clock; + it('should close tooltip on overflow button mouseover', () => { + mouseover(buttons[0]); + mouseover(buttons[buttons.length - 1]); + expect(tooltip.opened).to.be.false; + }); - beforeEach(() => { - clock = sinon.useFakeTimers({ - shouldClearNativeTimers: true, + it('should close tooltip on overflow button keyboard navigation', () => { + buttons[0].focus(); + arrowRight(buttons[0]); + expect(tooltip.opened).to.be.true; + arrowRight(buttons[1]); + expect(tooltip.opened).to.be.false; + }); + + it('should not show tooltip on overflow button keyboard focus', () => { + buttons[0].focus(); + arrowRight(buttons[0]); + arrowRight(buttons[1]); + tabKeyDown(document.body); + focusin(buttons[buttons.length - 1]); + expect(tooltip.opened).to.be.false; }); }); - afterEach(() => { - // Wait for cooldown - clock.tick(DEFAULT_DELAY); - clock.restore(); + describe('open on hover', () => { + beforeEach(() => { + menuBar.openOnHover = true; + }); + + it('should show tooltip on mouseover for button without children', () => { + menuBar.openOnHover = true; + mouseover(buttons[0]); + expect(tooltip.opened).to.be.true; + }); + + it('should not show tooltip on mouseover for button with children', () => { + menuBar.openOnHover = true; + mouseover(buttons[1]); + expect(tooltip.opened).to.be.false; + }); }); - it('should use hover delay on menu button mouseover', () => { - tooltip.hoverDelay = 100; + describe('delay', () => { + const DEFAULT_DELAY = 500; - mouseover(buttons[0]); - expect(tooltip.opened).to.be.false; + let clock; - clock.tick(100); - expect(tooltip.opened).to.be.true; + beforeEach(() => { + clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + // Wait for cooldown + clock.tick(DEFAULT_DELAY); + clock.restore(); + }); + + it('should use hover delay on menu button mouseover', () => { + tooltip.hoverDelay = 100; + + mouseover(buttons[0]); + expect(tooltip.opened).to.be.false; + + clock.tick(100); + expect(tooltip.opened).to.be.true; + }); + + it('should use hide delay on menu button mouseleave', () => { + tooltip.hideDelay = 100; + + mouseover(buttons[0]); + clock.tick(DEFAULT_DELAY); + + mouseleave(menuBar); + expect(tooltip.opened).to.be.true; + + clock.tick(100); + expect(tooltip.opened).to.be.false; + }); + + it('should not use hide delay on menu button mousedown', () => { + tooltip.hideDelay = 100; + + mouseover(buttons[0]); + clock.tick(DEFAULT_DELAY); + + mousedown(buttons[0]); + expect(tooltip.opened).to.be.false; + }); + + it('should use focus delay on menu button keyboard focus', () => { + tooltip.focusDelay = 100; + + tabKeyDown(document.body); + focusin(buttons[0]); + expect(tooltip.opened).to.be.false; + + clock.tick(100); + expect(tooltip.opened).to.be.true; + }); + + it('should not use hide delay on menu button Esc', () => { + tooltip.hideDelay = 100; + + tabKeyDown(document.body); + focusin(buttons[0]); + clock.tick(DEFAULT_DELAY); + + escKeyDown(buttons[0]); + expect(tooltip.opened).to.be.false; + }); }); + }); - it('should use hide delay on menu button mouseleave', () => { - tooltip.hideDelay = 100; + describe('accessible disabled button', () => { + before(() => { + window.Vaadin.featureFlags ??= {}; + window.Vaadin.featureFlags.accessibleDisabledButtons = true; + }); - mouseover(buttons[0]); - clock.tick(DEFAULT_DELAY); + after(() => { + window.Vaadin.featureFlags.accessibleDisabledButtons = false; + }); - mouseleave(menuBar); - expect(tooltip.opened).to.be.true; + beforeEach(async () => { + menuBar = fixtureSync( + `
+ + + + +
`, + ).firstElementChild; + + menuBar.items = [ + { text: 'Edit' }, + { + text: 'Copy', + tooltip: 'Copy tooltip', + disabled: true, + }, + ]; - clock.tick(100); - expect(tooltip.opened).to.be.false; + await nextRender(); + buttons = menuBar._buttons; + + tooltip = menuBar.querySelector('vaadin-tooltip'); }); - it('should not use hide delay on menu button mousedown', () => { - tooltip.hideDelay = 100; + afterEach(async () => { + await resetMouse(); + }); - mouseover(buttons[0]); - clock.tick(DEFAULT_DELAY); + it('should toggle tooltip on disabled button hover', async () => { + const { x, y } = middleOfNode(buttons[1]); + await sendMouse({ type: 'move', position: [Math.floor(x), Math.floor(y)] }); + expect(getTooltipText()).to.equal('Copy tooltip'); - mousedown(buttons[0]); - expect(tooltip.opened).to.be.false; + await sendMouse({ type: 'move', position: [0, 0] }); + expect(getTooltipText()).to.be.null; }); - it('should use focus delay on menu button keyboard focus', () => { - tooltip.focusDelay = 100; + it('should toggle tooltip on disabled button focus when navigating with arrow keys', async () => { + await sendKeys({ press: 'Tab' }); + expect(getTooltipText()).to.be.null; - tabKeyDown(document.body); - focusin(buttons[0]); - expect(tooltip.opened).to.be.false; + await sendKeys({ press: 'ArrowRight' }); + expect(getTooltipText()).to.equal('Copy tooltip'); - clock.tick(100); - expect(tooltip.opened).to.be.true; + await sendKeys({ press: 'ArrowLeft' }); + expect(getTooltipText()).to.be.null; }); - it('should not use hide delay on menu button Esc', () => { - tooltip.hideDelay = 100; + it('should toggle tooltip on disabled button focus when navigating with Tab', async () => { + menuBar.tabNavigation = true; - tabKeyDown(document.body); - focusin(buttons[0]); - clock.tick(DEFAULT_DELAY); + await sendKeys({ press: 'Tab' }); + expect(getTooltipText()).to.be.null; - escKeyDown(buttons[0]); - expect(tooltip.opened).to.be.false; + await sendKeys({ press: 'Tab' }); + expect(getTooltipText()).to.equal('Copy tooltip'); + + await sendKeys({ down: 'Shift' }); + await sendKeys({ press: 'Tab' }); + await sendKeys({ up: 'Shift' }); + expect(getTooltipText()).to.be.null; }); }); });