Skip to content

Commit 3c3bc2b

Browse files
nikkimkRajdeep Chandracaseyisonit
authored
fix(picker): menuitem focus when opened via mouse (#5358)
* fix(picker): only set focus on first item if keyboard * test(picker): updated tests are WIP * chore: added test to check for focus when closed via keyboard * chore: fix picker test for returning focus * chore: fix picker test for returning focus linted * test(action-group): adjusts timeout * test(action-group): adjusts timeout * test(action-group): adjusts timeout * fix(action-menu): make sure keyboard can focus even if mouse toggles menu * fix(action-menu): make sure keyboard can focus even if mouse toggles menu * chore(picker): added changeset --------- Co-authored-by: Rajdeep Chandra <[email protected]> Co-authored-by: Casey Eickhoff <[email protected]>
1 parent 3600d1e commit 3c3bc2b

File tree

4 files changed

+86
-30
lines changed

4 files changed

+86
-30
lines changed

.changeset/fluffy-sides-cross.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@spectrum-web-components/picker': patch
3+
---
4+
5+
`PickerBase`(used in `<sp-picker>` and `sp-action-menu>`):
6+
7+
Fixes focus so that it is not set on `<sp-menu-item>` elements when opened via mouse.
8+
9+
A keyboard interaction is the only interaction that should set focus on an `<sp-menu-item>` when the menu is opened. A user with a mouse would expect the focus to stay where the mouse is.
10+
11+
Fixes: #2950

packages/action-group/test/action-group.test.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,7 @@ describe('ActionGroup', () => {
177177
);
178178

179179
await elementUpdated(el);
180-
181-
await nextFrame();
182-
await nextFrame();
183-
await nextFrame();
184-
await nextFrame();
180+
await waitUntil(() => el.children.length === 4);
185181

186182
// press Tab to focus into the action-group
187183
await sendKeys({ press: 'Tab' });
@@ -269,7 +265,7 @@ describe('ActionGroup', () => {
269265

270266
await elementUpdated(el);
271267

272-
await aTimeout(500);
268+
await aTimeout(100);
273269

274270
// get the bounding box of the first button
275271
const firstButton = el.querySelector('#first') as ActionButton;
@@ -285,9 +281,7 @@ describe('ActionGroup', () => {
285281
},
286282
],
287283
});
288-
289-
await elementUpdated(el);
290-
await aTimeout(500);
284+
await elementUpdated(firstButton);
291285

292286
// expect all the elements of the focus group to have a tabIndex of -1 except the first button because it is focused using mouse
293287
expect(
@@ -318,7 +312,6 @@ describe('ActionGroup', () => {
318312
});
319313

320314
await elementUpdated(el);
321-
await aTimeout(500);
322315

323316
// expect the first button to have a tabIndex of 0 and everything else to have a tabIndex of -1
324317
expect(
@@ -338,9 +331,13 @@ describe('ActionGroup', () => {
338331
'mouse2: should not be focused on the fourth button'
339332
).to.equal(-1);
340333

334+
await elementUpdated(el);
335+
341336
// get the bounding box of the action-menu
342337
const actionMenu = el.querySelector('#action-menu') as ActionMenu;
343338
const actionMenuRect = actionMenu.getBoundingClientRect();
339+
340+
const opened = oneEvent(el.children[3] as ActionMenu, 'sp-opened');
344341
sendMouse({
345342
steps: [
346343
{
@@ -352,20 +349,19 @@ describe('ActionGroup', () => {
352349
},
353350
],
354351
});
355-
352+
await opened;
356353
await elementUpdated(el);
357354

358-
const opened = oneEvent(el.children[3] as ActionMenu, 'sp-opened');
359-
await opened;
355+
expect(actionMenu).to.equal(document.activeElement);
356+
const closed = oneEvent(el.children[3] as ActionMenu, 'sp-closed');
360357

361358
// use keyboard to navigate to the second menu item and select it
362359
await sendKeys({ press: 'ArrowDown' });
360+
expect(actionMenu.children[0]).to.equal(document.activeElement);
363361
await sendKeys({ press: 'Enter' });
364362

365-
const closed = oneEvent(el.children[3] as ActionMenu, 'sp-closed');
366363
await closed;
367364

368-
await aTimeout(500);
369365
expect(
370366
(el.children[0] as ActionButton)?.tabIndex,
371367
'final: should NOT be focused on the first button'

packages/picker/src/Picker.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,20 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
311311
};
312312

313313
protected async keyboardOpen(): Promise<void> {
314-
this.toggle(true);
314+
// if the menu is not open, we need to toggle it and wait for it to open to focus on the first selected item
315+
if (!this.open || !this.strategy.open) {
316+
this.addEventListener(
317+
'sp-opened',
318+
() => this.optionsMenu?.focusOnFirstSelectedItem(),
319+
{
320+
once: true,
321+
}
322+
);
323+
this.toggle(true);
324+
} else {
325+
// if the menu is already open, we need to focus on the first selected item
326+
this.optionsMenu?.focusOnFirstSelectedItem();
327+
}
315328
}
316329

317330
protected async setValueFromItem(
@@ -373,14 +386,6 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
373386
return;
374387
}
375388
const open = typeof target !== 'undefined' ? target : !this.open;
376-
if (open && !this.open)
377-
this.addEventListener(
378-
'sp-opened',
379-
() => this.optionsMenu?.focusOnFirstSelectedItem(),
380-
{
381-
once: true,
382-
}
383-
);
384389

385390
this.open = open;
386391
if (this.strategy) {

packages/picker/test/index.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ export function runPickerTests(): void {
544544
'first item should not be visually focused after closing'
545545
).to.be.false;
546546
});
547-
it('opens, on click, with visible focus on a menu item', async () => {
547+
it('opens, on click, with visible focus NOT on a menu item', async () => {
548548
await nextFrame();
549549
await nextFrame();
550550
const firstItem = el.querySelector('sp-menu-item') as MenuItem;
@@ -566,7 +566,9 @@ export function runPickerTests(): void {
566566
await opened;
567567

568568
expect(el.open, 'open?').to.be.true;
569-
expect(firstItem.focused, 'firstItem focused?').to.be.true;
569+
expect(firstItem.focused, 'firstItem focused after click?').to.be
570+
.false;
571+
expect(firstItem).to.not.equal(document.activeElement);
570572
});
571573
it('opens and selects in a single pointer button interaction', async () => {
572574
await nextFrame();
@@ -837,7 +839,20 @@ export function runPickerTests(): void {
837839
) as MenuItem;
838840

839841
const opened = oneEvent(el, 'sp-opened');
840-
el.click();
842+
843+
const boundingRect = el.button.getBoundingClientRect();
844+
sendMouse({
845+
steps: [
846+
{
847+
type: 'click',
848+
position: [
849+
boundingRect.x + boundingRect.width / 2,
850+
boundingRect.y + boundingRect.height / 2,
851+
],
852+
},
853+
],
854+
});
855+
841856
await opened;
842857
await elementUpdated(el);
843858

@@ -880,6 +895,7 @@ export function runPickerTests(): void {
880895
input.focus();
881896
});
882897

898+
// Clicking on an item in the picker triggers a change event
883899
secondItem.click();
884900
await waitUntil(
885901
() => document.activeElement === input,
@@ -1369,7 +1385,7 @@ export function runPickerTests(): void {
13691385
// we should have SAFARI_FOCUS_RING_CLASS in the classList
13701386
expect(
13711387
button.classList.contains(SAFARI_FOCUS_RING_CLASS),
1372-
'has focus ring?'
1388+
'button has focus ring?'
13731389
).to.be.true;
13741390

13751391
// picker should still have focus
@@ -1396,20 +1412,48 @@ export function runPickerTests(): void {
13961412
});
13971413
await opened;
13981414

1415+
expect(firstItem.focused, 'firstItem focused?').to.be.true;
1416+
13991417
// Make a selection again
14001418
closed = oneEvent(el, 'sp-closed');
1401-
firstItem.click();
1419+
await sendKeys({
1420+
press: 'Enter',
1421+
});
14021422
await closed;
14031423

14041424
await elementUpdated(el);
14051425

14061426
// expect the tray to be closed
14071427
expect(el.open, 'open?').to.be.false;
1428+
// Test focus behavior when using keyboard to close
1429+
expect(
1430+
document.activeElement,
1431+
'focus should be on picker after keyboard close'
1432+
).to.equal(el);
1433+
1434+
// Verify that focus is maintained on the picker element when closed via keyboard
1435+
expect(
1436+
el.contains(document.activeElement) ||
1437+
el === document.activeElement,
1438+
'focus should remain within picker component after keyboard close'
1439+
).to.be.true;
1440+
1441+
// Click elsewhere to remove focus completely
1442+
await sendMouse({
1443+
steps: [
1444+
{
1445+
type: 'click',
1446+
position: [0, 0],
1447+
},
1448+
],
1449+
});
14081450

1451+
// Now picker should not have focus
1452+
expect(document.activeElement).not.to.equal(el);
14091453
// we should not have SAFARI_FOCUS_RING_CLASS in the classList
14101454
expect(
14111455
button.classList.contains(SAFARI_FOCUS_RING_CLASS),
1412-
'has focus ring?'
1456+
'has focus ring again?'
14131457
).to.be.false;
14141458
});
14151459
it('does not close on document scroll', async () => {

0 commit comments

Comments
 (0)