Skip to content

Commit 2bb0a55

Browse files
committed
fix(menu): support focus first/last item via home/end keys
Adds support for jumping to the first/last item using the home/end keys.
1 parent 88601fa commit 2bb0a55

File tree

2 files changed

+119
-4
lines changed

2 files changed

+119
-4
lines changed

src/lib/menu/menu-directive.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@
99
import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y';
1010
import {Direction} from '@angular/cdk/bidi';
1111
import {coerceBooleanProperty} from '@angular/cdk/coercion';
12-
import {ESCAPE, LEFT_ARROW, RIGHT_ARROW, DOWN_ARROW, UP_ARROW} from '@angular/cdk/keycodes';
12+
import {
13+
ESCAPE,
14+
LEFT_ARROW,
15+
RIGHT_ARROW,
16+
DOWN_ARROW,
17+
UP_ARROW,
18+
HOME,
19+
END,
20+
hasModifierKey,
21+
} from '@angular/cdk/keycodes';
1322
import {
1423
AfterContentInit,
1524
ChangeDetectionStrategy,
@@ -258,6 +267,7 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
258267
/** Handle a keyboard event from the menu, delegating to the appropriate action. */
259268
_handleKeydown(event: KeyboardEvent) {
260269
const keyCode = event.keyCode;
270+
const manager = this._keyManager;
261271

262272
switch (keyCode) {
263273
case ESCAPE:
@@ -273,12 +283,19 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
273283
this.closed.emit('keydown');
274284
}
275285
break;
286+
case HOME:
287+
case END:
288+
if (!hasModifierKey(event)) {
289+
keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
290+
event.preventDefault();
291+
}
292+
break;
276293
default:
277294
if (keyCode === UP_ARROW || keyCode === DOWN_ARROW) {
278-
this._keyManager.setFocusOrigin('keyboard');
295+
manager.setFocusOrigin('keyboard');
279296
}
280297

281-
this._keyManager.onKeydown(event);
298+
manager.onKeydown(event);
282299
}
283300
}
284301

src/lib/menu/menu.spec.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@angular/core';
1818
import {Direction, Directionality} from '@angular/cdk/bidi';
1919
import {OverlayContainer, Overlay} from '@angular/cdk/overlay';
20-
import {ESCAPE, LEFT_ARROW, RIGHT_ARROW, DOWN_ARROW, TAB} from '@angular/cdk/keycodes';
20+
import {ESCAPE, LEFT_ARROW, RIGHT_ARROW, DOWN_ARROW, TAB, HOME, END} from '@angular/cdk/keycodes';
2121
import {
2222
MAT_MENU_DEFAULT_OPTIONS,
2323
MatMenu,
@@ -601,6 +601,104 @@ describe('MatMenu', () => {
601601
expect(overlayContainerElement.textContent).toBe('');
602602
}));
603603

604+
it('should focus the first item when pressing home', fakeAsync(() => {
605+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
606+
fixture.detectChanges();
607+
608+
fixture.componentInstance.trigger.openMenu();
609+
fixture.detectChanges();
610+
611+
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
612+
const items = Array.from(panel.querySelectorAll('.mat-menu-item')) as HTMLElement[];
613+
items.forEach(patchElementFocus);
614+
615+
// Focus the last item since focus starts from the first one.
616+
items[items.length - 1].focus();
617+
fixture.detectChanges();
618+
619+
spyOn(items[0], 'focus').and.callThrough();
620+
621+
const event = dispatchKeyboardEvent(panel, 'keydown', HOME);
622+
fixture.detectChanges();
623+
624+
expect(items[0].focus).toHaveBeenCalled();
625+
expect(event.defaultPrevented).toBe(true);
626+
flush();
627+
}));
628+
629+
it('should not focus the first item when pressing home with a modifier key', fakeAsync(() => {
630+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
631+
fixture.detectChanges();
632+
633+
fixture.componentInstance.trigger.openMenu();
634+
fixture.detectChanges();
635+
636+
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
637+
const items = Array.from(panel.querySelectorAll('.mat-menu-item')) as HTMLElement[];
638+
items.forEach(patchElementFocus);
639+
640+
// Focus the last item since focus starts from the first one.
641+
items[items.length - 1].focus();
642+
fixture.detectChanges();
643+
644+
spyOn(items[0], 'focus').and.callThrough();
645+
646+
const event = createKeyboardEvent('keydown', HOME);
647+
Object.defineProperty(event, 'altKey', {get: () => true});
648+
649+
dispatchEvent(panel, event);
650+
fixture.detectChanges();
651+
652+
expect(items[0].focus).not.toHaveBeenCalled();
653+
expect(event.defaultPrevented).toBe(false);
654+
flush();
655+
}));
656+
657+
it('should focus the last item when pressing end', fakeAsync(() => {
658+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
659+
fixture.detectChanges();
660+
661+
fixture.componentInstance.trigger.openMenu();
662+
fixture.detectChanges();
663+
664+
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
665+
const items = Array.from(panel.querySelectorAll('.mat-menu-item')) as HTMLElement[];
666+
items.forEach(patchElementFocus);
667+
668+
spyOn(items[items.length - 1], 'focus').and.callThrough();
669+
670+
const event = dispatchKeyboardEvent(panel, 'keydown', END);
671+
fixture.detectChanges();
672+
673+
expect(items[items.length - 1].focus).toHaveBeenCalled();
674+
expect(event.defaultPrevented).toBe(true);
675+
flush();
676+
}));
677+
678+
it('should not focus the last item when pressing end with a modifier key', fakeAsync(() => {
679+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
680+
fixture.detectChanges();
681+
682+
fixture.componentInstance.trigger.openMenu();
683+
fixture.detectChanges();
684+
685+
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
686+
const items = Array.from(panel.querySelectorAll('.mat-menu-item')) as HTMLElement[];
687+
items.forEach(patchElementFocus);
688+
689+
spyOn(items[items.length - 1], 'focus').and.callThrough();
690+
691+
const event = createKeyboardEvent('keydown', END);
692+
Object.defineProperty(event, 'altKey', {get: () => true});
693+
694+
dispatchEvent(panel, event);
695+
fixture.detectChanges();
696+
697+
expect(items[items.length - 1].focus).not.toHaveBeenCalled();
698+
expect(event.defaultPrevented).toBe(false);
699+
flush();
700+
}));
701+
604702
describe('lazy rendering', () => {
605703
it('should be able to render the menu content lazily', fakeAsync(() => {
606704
const fixture = createComponent(SimpleLazyMenu);

0 commit comments

Comments
 (0)