Skip to content

Commit 686150f

Browse files
authored
fix: ensure menu-bar buttons always have correct tabindex (#10356)
1 parent 1a10749 commit 686150f

File tree

5 files changed

+137
-33
lines changed

5 files changed

+137
-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 { Debouncer } from '@vaadin/component-base/src/debounce.js';
@@ -500,12 +500,6 @@ export const MenuBarMixin = (superClass) =>
500500

501501
const items = buttons.filter((b) => !remaining.includes(b)).map((b) => b.item);
502502
this.__updateOverflow(items);
503-
504-
// Ensure there is at least one button with tabindex set to 0
505-
// so that menu-bar is not skipped when navigating with Tab
506-
if (remaining.length && !remaining.some((btn) => btn.getAttribute('tabindex') === '0')) {
507-
this._setTabindex(remaining[remaining.length - 1], true);
508-
}
509503
}
510504
}
511505

@@ -536,13 +530,23 @@ export const MenuBarMixin = (superClass) =>
536530
const isSingleButton = newOverflowCount === buttons.length || (newOverflowCount === 0 && buttons.length === 1);
537531
this.toggleAttribute('has-single-button', isSingleButton);
538532

533+
// Collect visible buttons to detect if tabindex should be updated
534+
const visibleButtons = buttons.filter((btn) => btn.style.visibility !== 'hidden');
535+
536+
if (!visibleButtons.length) {
537+
// If all buttons except overflow are hidden, set tabindex on it
538+
this._overflow.setAttribute('tabindex', '0');
539+
} else if (!visibleButtons.some((btn) => btn.getAttribute('tabindex') === '0')) {
540+
// Ensure there is at least one button with tabindex set to 0
541+
// so that menu-bar is not skipped when navigating with Tab
542+
this._setTabindex(visibleButtons[visibleButtons.length - 1], true);
543+
}
544+
539545
// Apply first/last visible attributes to the visible buttons
540-
buttons
541-
.filter((btn) => btn.style.visibility !== 'hidden')
542-
.forEach((btn, index, visibleButtons) => {
543-
btn.toggleAttribute('first-visible', index === 0);
544-
btn.toggleAttribute('last-visible', !this._hasOverflow && index === visibleButtons.length - 1);
545-
});
546+
visibleButtons.forEach((btn, index, visibleButtons) => {
547+
btn.toggleAttribute('first-visible', index === 0);
548+
btn.toggleAttribute('last-visible', !this._hasOverflow && index === visibleButtons.length - 1);
549+
});
546550
}
547551

548552
/** @private */
@@ -761,8 +765,7 @@ export const MenuBarMixin = (superClass) =>
761765
*/
762766
_setFocused(focused) {
763767
if (focused) {
764-
const selector = this.tabNavigation ? '[focused]' : '[tabindex="0"]';
765-
const target = this.querySelector(`vaadin-menu-bar-button${selector}`);
768+
const target = this.__getFocusTarget();
766769
if (target) {
767770
this._buttons.forEach((btn) => {
768771
this._setTabindex(btn, btn === target);
@@ -776,6 +779,24 @@ export const MenuBarMixin = (superClass) =>
776779
}
777780
}
778781

782+
/** @private */
783+
__getFocusTarget() {
784+
// First, check if focus is moving to a visible button
785+
let target = this._buttons.find((btn) => isElementFocused(btn));
786+
787+
if (!target) {
788+
const selector = this.tabNavigation ? '[focused]' : '[tabindex="0"]';
789+
// Next, check if there is a button that could be focused but is hidden
790+
target = this.querySelector(`vaadin-menu-bar-button${selector}`);
791+
792+
if (isElementHidden(target)) {
793+
target = this._buttons[this._getFocusableIndex()];
794+
}
795+
}
796+
797+
return target;
798+
}
799+
779800
/**
780801
* @param {!KeyboardEvent} event
781802
* @private

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

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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';
55
import '../src/vaadin-menu-bar.js';
66

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

143219
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,
@@ -144,7 +143,7 @@ describe('menu-bar with tooltip', () => {
144143

145144
it('should show tooltip on menu button keyboard focus', () => {
146145
tabKeyDown(document.body);
147-
focusin(buttons[0]);
146+
buttons[0].focus();
148147
expect(tooltip.opened).to.be.true;
149148
});
150149

@@ -159,27 +158,27 @@ describe('menu-bar with tooltip', () => {
159158

160159
it('should set tooltip target on menu button keyboard focus', () => {
161160
tabKeyDown(document.body);
162-
focusin(buttons[0]);
161+
buttons[0].focus();
163162
expect(tooltip.target).to.be.equal(buttons[0]);
164163
});
165164

166165
it('should set tooltip context on menu button keyboard focus', () => {
167166
tabKeyDown(document.body);
168-
focusin(buttons[0]);
167+
buttons[0].focus();
169168
expect(tooltip.context).to.be.instanceOf(Object);
170169
expect(tooltip.context.item.text).to.equal('Edit');
171170
});
172171

173172
it('should hide tooltip on menu-bar focusout', () => {
174173
tabKeyDown(document.body);
175-
focusin(buttons[0]);
174+
buttons[0].focus();
176175
focusout(menuBar);
177176
expect(tooltip.opened).to.be.false;
178177
});
179178

180179
it('should hide tooltip on menuBar menu button content Esc', () => {
181180
tabKeyDown(document.body);
182-
focusin(buttons[0]);
181+
buttons[0].focus();
183182
escKeyDown(buttons[0]);
184183
expect(tooltip.opened).to.be.false;
185184
});
@@ -250,7 +249,7 @@ describe('menu-bar with tooltip', () => {
250249
arrowRight(buttons[0]);
251250
arrowRight(buttons[1]);
252251
tabKeyDown(document.body);
253-
focusin(buttons[buttons.length - 1]);
252+
menuBar._overflow.focus();
254253
expect(tooltip.opened).to.be.false;
255254
});
256255
});
@@ -327,7 +326,7 @@ describe('menu-bar with tooltip', () => {
327326
tooltip.focusDelay = 100;
328327

329328
tabKeyDown(document.body);
330-
focusin(buttons[0]);
329+
buttons[0].focus();
331330
expect(tooltip.opened).to.be.false;
332331

333332
clock.tick(100);
@@ -338,7 +337,7 @@ describe('menu-bar with tooltip', () => {
338337
tooltip.hideDelay = 100;
339338

340339
tabKeyDown(document.body);
341-
focusin(buttons[0]);
340+
buttons[0].focus();
342341
clock.tick(DEFAULT_DELAY);
343342

344343
escKeyDown(buttons[0]);

0 commit comments

Comments
 (0)