Skip to content

Commit b58641c

Browse files
fix: ensure menu-bar buttons always have correct tabindex (#10356) (#10362)
Co-authored-by: Serhii Kulykov <iamkulykov@gmail.com>
1 parent 6ce9b01 commit b58641c

File tree

5 files changed

+138
-33
lines changed

5 files changed

+138
-33
lines changed

packages/a11y-base/src/keyboard-direction-mixin.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,23 @@ export const KeyboardDirectionMixin = (superclass) =>
4444
* @override
4545
*/
4646
focus(options) {
47-
const items = this._getItems();
48-
if (Array.isArray(items)) {
49-
const idx = this._getAvailableIndex(items, 0, null, (item) => !isElementHidden(item));
50-
if (idx >= 0) {
51-
this._focus(idx, options);
52-
}
47+
const idx = this._getFocusableIndex();
48+
if (idx >= 0) {
49+
this._focus(idx, options);
5350
}
5451
}
5552

53+
/**
54+
* Get the index of a first focusable item, if any.
55+
*
56+
* @return {Element[]}
57+
* @protected
58+
*/
59+
_getFocusableIndex() {
60+
const items = this._getItems();
61+
return Array.isArray(items) ? this._getAvailableIndex(items, 0, null, (item) => !isElementHidden(item)) : -1;
62+
}
63+
5664
/**
5765
* Get the list of items participating in keyboard navigation.
5866
* By default, it treats all the light DOM children as items.

packages/menu-bar/src/vaadin-menu-bar-mixin.js

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Directive, directive } from 'lit/directive.js';
88
import { ifDefined } from 'lit/directives/if-defined.js';
99
import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js';
1010
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
11-
import { isElementFocused, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
11+
import { isElementFocused, isElementHidden, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
1212
import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
1313
import { microTask } from '@vaadin/component-base/src/async.js';
1414
import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js';
@@ -549,12 +549,6 @@ export const MenuBarMixin = (superClass) =>
549549

550550
const items = buttons.filter((b) => !remaining.includes(b)).map((b) => b.item);
551551
this.__updateOverflow(items);
552-
553-
// Ensure there is at least one button with tabindex set to 0
554-
// so that menu-bar is not skipped when navigating with Tab
555-
if (remaining.length && !remaining.some((btn) => btn.getAttribute('tabindex') === '0')) {
556-
this._setTabindex(remaining[remaining.length - 1], true);
557-
}
558552
}
559553
}
560554

@@ -585,13 +579,23 @@ export const MenuBarMixin = (superClass) =>
585579
const isSingleButton = newOverflowCount === buttons.length || (newOverflowCount === 0 && buttons.length === 1);
586580
this.toggleAttribute('has-single-button', isSingleButton);
587581

582+
// Collect visible buttons to detect if tabindex should be updated
583+
const visibleButtons = buttons.filter((btn) => btn.style.visibility !== 'hidden');
584+
585+
if (!visibleButtons.length) {
586+
// If all buttons except overflow are hidden, set tabindex on it
587+
this._overflow.setAttribute('tabindex', '0');
588+
} else if (!visibleButtons.some((btn) => btn.getAttribute('tabindex') === '0')) {
589+
// Ensure there is at least one button with tabindex set to 0
590+
// so that menu-bar is not skipped when navigating with Tab
591+
this._setTabindex(visibleButtons[visibleButtons.length - 1], true);
592+
}
593+
588594
// Apply first/last visible attributes to the visible buttons
589-
buttons
590-
.filter((btn) => btn.style.visibility !== 'hidden')
591-
.forEach((btn, index, visibleButtons) => {
592-
btn.toggleAttribute('first-visible', index === 0);
593-
btn.toggleAttribute('last-visible', !this._hasOverflow && index === visibleButtons.length - 1);
594-
});
595+
visibleButtons.forEach((btn, index, visibleButtons) => {
596+
btn.toggleAttribute('first-visible', index === 0);
597+
btn.toggleAttribute('last-visible', !this._hasOverflow && index === visibleButtons.length - 1);
598+
});
595599
}
596600

597601
/** @private */
@@ -781,8 +785,7 @@ export const MenuBarMixin = (superClass) =>
781785
*/
782786
_setFocused(focused) {
783787
if (focused) {
784-
const selector = this.tabNavigation ? '[focused]' : '[tabindex="0"]';
785-
const target = this.querySelector(`vaadin-menu-bar-button${selector}`);
788+
const target = this.__getFocusTarget();
786789
if (target) {
787790
this._buttons.forEach((btn) => {
788791
this._setTabindex(btn, btn === target);
@@ -796,6 +799,24 @@ export const MenuBarMixin = (superClass) =>
796799
}
797800
}
798801

802+
/** @private */
803+
__getFocusTarget() {
804+
// First, check if focus is moving to a visible button
805+
let target = this._buttons.find((btn) => isElementFocused(btn));
806+
807+
if (!target) {
808+
const selector = this.tabNavigation ? '[focused]' : '[tabindex="0"]';
809+
// Next, check if there is a button that could be focused but is hidden
810+
target = this.querySelector(`vaadin-menu-bar-button${selector}`);
811+
812+
if (isElementHidden(target)) {
813+
target = this._buttons[this._getFocusableIndex()];
814+
}
815+
}
816+
817+
return target;
818+
}
819+
799820
/**
800821
* @param {!KeyboardEvent} event
801822
* @private

packages/menu-bar/test/keyboard-navigation.test.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { expect } from '@vaadin/chai-plugins';
22
import { sendKeys } from '@vaadin/test-runner-commands';
3-
import { fixtureSync, nextRender, nextUpdate } from '@vaadin/testing-helpers';
3+
import { fixtureSync, nextRender, nextResize, nextUpdate } from '@vaadin/testing-helpers';
44
import sinon from 'sinon';
5+
import './menu-bar-test-styles.js';
56
import '../src/vaadin-menu-bar.js';
67

78
describe('keyboard navigation', () => {
@@ -138,6 +139,82 @@ describe('keyboard navigation', () => {
138139
expect(buttons[3].hasAttribute('focused')).to.be.true;
139140
});
140141
});
142+
143+
describe('overflow', () => {
144+
beforeEach(async () => {
145+
menu.items = [{ text: 'Item Foo' }, { text: 'Item Bar' }];
146+
await nextRender();
147+
buttons = menu._buttons;
148+
});
149+
150+
it('should move focus back to the overflow button on Shift + Tab after Tab', async () => {
151+
// Hide all buttons except overflow
152+
menu.style.width = '100px';
153+
await nextResize(menu);
154+
155+
firstGlobalFocusable.focus();
156+
await sendKeys({ press: 'Tab' });
157+
expect(document.activeElement).to.equal(menu._overflow);
158+
159+
await sendKeys({ press: 'Tab' });
160+
expect(document.activeElement).to.equal(lastGlobalFocusable);
161+
162+
await sendKeys({ press: 'Shift+Tab' });
163+
expect(document.activeElement).to.equal(menu._overflow);
164+
});
165+
166+
it('should move focus back to the overflow button on Tab after Shift + Tab', async () => {
167+
// Show 1 button + overflow
168+
menu.style.width = '120px';
169+
await nextResize(menu);
170+
171+
lastGlobalFocusable.focus();
172+
await sendKeys({ press: 'Shift+Tab' });
173+
expect(document.activeElement).to.equal(menu._overflow);
174+
175+
await sendKeys({ press: 'Shift+Tab' });
176+
expect(document.activeElement).to.equal(firstGlobalFocusable);
177+
178+
await sendKeys({ press: 'Tab' });
179+
expect(document.activeElement).to.equal(menu._overflow);
180+
});
181+
182+
it('should move focus back to the overflow button on Tab after button is hidden', async () => {
183+
lastGlobalFocusable.focus();
184+
await sendKeys({ press: 'Shift+Tab' });
185+
expect(document.activeElement).to.equal(buttons[1]);
186+
187+
await sendKeys({ press: 'Shift+Tab' });
188+
expect(document.activeElement).to.equal(firstGlobalFocusable);
189+
190+
// Hide all buttons except overflow
191+
menu.style.width = '100px';
192+
await nextResize(menu);
193+
194+
await sendKeys({ press: 'Tab' });
195+
expect(document.activeElement).to.equal(menu._overflow);
196+
});
197+
198+
it('should not skip buttons on Tab after overflow becomes hidden', async () => {
199+
// Hide all buttons except overflow
200+
menu.style.width = '100px';
201+
await nextResize(menu);
202+
203+
firstGlobalFocusable.focus();
204+
await sendKeys({ press: 'Tab' });
205+
expect(document.activeElement).to.equal(menu._overflow);
206+
207+
await sendKeys({ press: 'Tab' });
208+
expect(document.activeElement).to.equal(lastGlobalFocusable);
209+
210+
// Show all buttons and hide overflow
211+
menu.style.width = '100%';
212+
await nextResize(menu);
213+
214+
await sendKeys({ press: 'Shift+Tab' });
215+
expect(document.activeElement).to.equal(buttons[1]);
216+
});
217+
});
141218
});
142219

143220
describe('tab navigation mode', () => {

packages/menu-bar/test/menu-bar.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@vaadin/chai-plugins';
2-
import { fixtureSync, focusin, nextRender, nextUpdate } from '@vaadin/testing-helpers';
2+
import { fixtureSync, nextRender, nextUpdate } from '@vaadin/testing-helpers';
33
import '../src/vaadin-menu-bar.js';
44

55
describe('custom element definition', () => {
@@ -71,7 +71,7 @@ describe('root menu layout', () => {
7171
});
7272

7373
it('should set tabindex to -1 to all the buttons except first one', () => {
74-
focusin(menu);
74+
menu.focus();
7575
expect(buttons[0].getAttribute('tabindex')).to.equal('0');
7676
buttons.slice(1).forEach((btn) => {
7777
expect(btn.getAttribute('tabindex')).to.equal('-1');

test/integration/menu-bar-tooltip.test.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
escKeyDown,
77
fire,
88
fixtureSync,
9-
focusin,
109
focusout,
1110
mousedown,
1211
nextRender,
@@ -149,7 +148,7 @@ describe('menu-bar with tooltip', () => {
149148

150149
it('should show tooltip on menu button keyboard focus', () => {
151150
tabKeyDown(document.body);
152-
focusin(buttons[0]);
151+
buttons[0].focus();
153152
expect(tooltip.opened).to.be.true;
154153
});
155154

@@ -164,27 +163,27 @@ describe('menu-bar with tooltip', () => {
164163

165164
it('should set tooltip target on menu button keyboard focus', () => {
166165
tabKeyDown(document.body);
167-
focusin(buttons[0]);
166+
buttons[0].focus();
168167
expect(tooltip.target).to.be.equal(buttons[0]);
169168
});
170169

171170
it('should set tooltip context on menu button keyboard focus', () => {
172171
tabKeyDown(document.body);
173-
focusin(buttons[0]);
172+
buttons[0].focus();
174173
expect(tooltip.context).to.be.instanceOf(Object);
175174
expect(tooltip.context.item.text).to.equal('Edit');
176175
});
177176

178177
it('should hide tooltip on menu-bar focusout', () => {
179178
tabKeyDown(document.body);
180-
focusin(buttons[0]);
179+
buttons[0].focus();
181180
focusout(menuBar);
182181
expect(tooltip.opened).to.be.false;
183182
});
184183

185184
it('should hide tooltip on menuBar menu button content Esc', () => {
186185
tabKeyDown(document.body);
187-
focusin(buttons[0]);
186+
buttons[0].focus();
188187
escKeyDown(buttons[0]);
189188
expect(tooltip.opened).to.be.false;
190189
});
@@ -255,7 +254,7 @@ describe('menu-bar with tooltip', () => {
255254
arrowRight(buttons[0]);
256255
arrowRight(buttons[1]);
257256
tabKeyDown(document.body);
258-
focusin(buttons[buttons.length - 1]);
257+
menuBar._overflow.focus();
259258
expect(tooltip.opened).to.be.false;
260259
});
261260
});
@@ -332,7 +331,7 @@ describe('menu-bar with tooltip', () => {
332331
tooltip.focusDelay = 100;
333332

334333
tabKeyDown(document.body);
335-
focusin(buttons[0]);
334+
buttons[0].focus();
336335
expect(tooltip.opened).to.be.false;
337336

338337
clock.tick(100);
@@ -343,7 +342,7 @@ describe('menu-bar with tooltip', () => {
343342
tooltip.hideDelay = 100;
344343

345344
tabKeyDown(document.body);
346-
focusin(buttons[0]);
345+
buttons[0].focus();
347346
clock.tick(DEFAULT_DELAY);
348347

349348
escKeyDown(buttons[0]);

0 commit comments

Comments
 (0)