Skip to content

Commit 58f047b

Browse files
authored
refactor(material/menu): Remove use of zone onStable to focus items (#28663)
1 parent 8583ca6 commit 58f047b

File tree

2 files changed

+79
-93
lines changed

2 files changed

+79
-93
lines changed

src/material/menu/menu.spec.ts

Lines changed: 37 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,53 @@
1-
import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
2-
import {By} from '@angular/platform-browser';
3-
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
1+
import {FocusMonitor} from '@angular/cdk/a11y';
2+
import {Direction, Directionality} from '@angular/cdk/bidi';
3+
import {
4+
DOWN_ARROW,
5+
END,
6+
ENTER,
7+
ESCAPE,
8+
HOME,
9+
LEFT_ARROW,
10+
RIGHT_ARROW,
11+
TAB,
12+
} from '@angular/cdk/keycodes';
13+
import {Overlay, OverlayContainer} from '@angular/cdk/overlay';
14+
import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
415
import {
16+
ChangeDetectionStrategy,
517
Component,
618
ElementRef,
719
EventEmitter,
820
Input,
921
Output,
10-
NgZone,
22+
Provider,
23+
QueryList,
1124
TemplateRef,
25+
Type,
1226
ViewChild,
1327
ViewChildren,
14-
QueryList,
15-
Type,
16-
Provider,
17-
ChangeDetectionStrategy,
1828
} from '@angular/core';
19-
import {Direction, Directionality} from '@angular/cdk/bidi';
20-
import {OverlayContainer, Overlay} from '@angular/cdk/overlay';
21-
import {
22-
ESCAPE,
23-
LEFT_ARROW,
24-
RIGHT_ARROW,
25-
DOWN_ARROW,
26-
TAB,
27-
HOME,
28-
END,
29-
ENTER,
30-
} from '@angular/cdk/keycodes';
31-
import {MatMenu, MatMenuModule, MatMenuItem} from './index';
29+
import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
3230
import {MatRipple} from '@angular/material/core';
31+
import {By} from '@angular/platform-browser';
32+
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
33+
import {Subject} from 'rxjs';
3334
import {
34-
dispatchKeyboardEvent,
35-
dispatchMouseEvent,
36-
dispatchEvent,
3735
createKeyboardEvent,
3836
createMouseEvent,
37+
dispatchEvent,
3938
dispatchFakeEvent,
39+
dispatchKeyboardEvent,
40+
dispatchMouseEvent,
4041
patchElementFocus,
41-
MockNgZone,
4242
} from '../../cdk/testing/private';
43-
import {Subject} from 'rxjs';
44-
import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
45-
import {FocusMonitor} from '@angular/cdk/a11y';
43+
import {MatMenu, MatMenuItem, MatMenuModule} from './index';
4644
import {
45+
MAT_MENU_DEFAULT_OPTIONS,
4746
MAT_MENU_SCROLL_STRATEGY,
47+
MatMenuPanel,
4848
MatMenuTrigger,
4949
MenuPositionX,
5050
MenuPositionY,
51-
MatMenuPanel,
52-
MAT_MENU_DEFAULT_OPTIONS,
5351
} from './public-api';
5452

5553
const MENU_PANEL_TOP_PADDING = 8;
@@ -772,27 +770,14 @@ describe('MDC-based MatMenu', () => {
772770
}));
773771

774772
it('should set the proper origin when calling focusFirstItem after the opening sequence has started', () => {
775-
let zone: MockNgZone;
776-
const fixture = createComponent(
777-
SimpleMenu,
778-
[
779-
{
780-
provide: NgZone,
781-
useFactory: () => (zone = new MockNgZone()),
782-
},
783-
],
784-
[FakeIcon],
785-
);
773+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
786774
fixture.detectChanges();
787775
spyOn(fixture.componentInstance.items.first, 'focus').and.callThrough();
788776

789-
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
790-
791-
dispatchMouseEvent(triggerEl, 'mousedown');
792-
triggerEl.click();
793-
fixture.detectChanges();
777+
fixture.componentInstance.trigger.openMenu();
778+
fixture.componentInstance.menu.focusFirstItem('mouse');
794779
fixture.componentInstance.menu.focusFirstItem('touch');
795-
zone!.onStable.next();
780+
fixture.detectChanges();
796781

797782
expect(fixture.componentInstance.items.first.focus).toHaveBeenCalledOnceWith('touch');
798783
});
@@ -1304,30 +1289,18 @@ describe('MDC-based MatMenu', () => {
13041289
expect(trigger.menuOpen).withContext('Expected menu to be closed').toBe(false);
13051290
}));
13061291

1307-
it('should focus the first menu item when opening a lazy menu via keyboard', fakeAsync(() => {
1308-
let zone: MockNgZone;
1309-
let fixture = createComponent(SimpleLazyMenu, [
1310-
{
1311-
provide: NgZone,
1312-
useFactory: () => (zone = new MockNgZone()),
1313-
},
1314-
]);
1315-
1316-
fixture.detectChanges();
1292+
it('should focus the first menu item when opening a lazy menu via keyboard', async () => {
1293+
const fixture = createComponent(SimpleLazyMenu);
1294+
fixture.autoDetectChanges();
13171295

13181296
// A click without a mousedown before it is considered a keyboard open.
13191297
fixture.componentInstance.triggerEl.nativeElement.click();
1320-
fixture.detectChanges();
1321-
tick(500);
1322-
zone!.simulateZoneExit();
1323-
1324-
// Flush due to the additional tick that is necessary for the FocusMonitor.
1325-
flush();
1298+
await fixture.whenStable();
13261299

13271300
const item = document.querySelector('.mat-mdc-menu-panel [mat-menu-item]')!;
13281301

13291302
expect(document.activeElement).withContext('Expected first item to be focused').toBe(item);
1330-
}));
1303+
});
13311304

13321305
it('should be able to open the same menu with a different context', fakeAsync(() => {
13331306
const fixture = createComponent(LazyMenuWithContext);

src/material/menu/menu.ts

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import {
2727
OnInit,
2828
ChangeDetectorRef,
2929
booleanAttribute,
30+
afterNextRender,
31+
AfterRenderRef,
32+
inject,
33+
Injector,
3034
} from '@angular/core';
3135
import {AnimationEvent} from '@angular/animations';
3236
import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y';
@@ -39,8 +43,8 @@ import {
3943
UP_ARROW,
4044
hasModifierKey,
4145
} from '@angular/cdk/keycodes';
42-
import {merge, Observable, Subject, Subscription} from 'rxjs';
43-
import {startWith, switchMap, take} from 'rxjs/operators';
46+
import {merge, Observable, Subject} from 'rxjs';
47+
import {startWith, switchMap} from 'rxjs/operators';
4448
import {MatMenuItem} from './menu-item';
4549
import {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel';
4650
import {MenuPositionX, MenuPositionY} from './menu-positions';
@@ -115,7 +119,7 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
115119
private _keyManager: FocusKeyManager<MatMenuItem>;
116120
private _xPosition: MenuPositionX;
117121
private _yPosition: MenuPositionY;
118-
private _firstItemFocusSubscription?: Subscription;
122+
private _firstItemFocusRef?: AfterRenderRef;
119123
private _previousElevation: string;
120124
private _elevationPrefix = 'mat-elevation-z';
121125
private _baseElevation = 8;
@@ -267,6 +271,8 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
267271

268272
readonly panelId = `mat-menu-panel-${menuPanelUid++}`;
269273

274+
private _injector = inject(Injector);
275+
270276
constructor(
271277
elementRef: ElementRef<HTMLElement>,
272278
ngZone: NgZone,
@@ -287,7 +293,11 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
287293

288294
constructor(
289295
private _elementRef: ElementRef<HTMLElement>,
290-
private _ngZone: NgZone,
296+
/**
297+
* @deprecated Unused param, will be removed.
298+
* @breaking-change 19.0.0
299+
*/
300+
_unusedNgZone: NgZone,
291301
@Inject(MAT_MENU_DEFAULT_OPTIONS) defaultOptions: MatMenuDefaultOptions,
292302
// @breaking-change 15.0.0 `_changeDetectorRef` to become a required parameter.
293303
private _changeDetectorRef?: ChangeDetectorRef,
@@ -345,7 +355,7 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
345355
this._keyManager?.destroy();
346356
this._directDescendantItems.destroy();
347357
this.closed.complete();
348-
this._firstItemFocusSubscription?.unsubscribe();
358+
this._firstItemFocusRef?.destroy();
349359
}
350360

351361
/** Stream that emits whenever the hovered menu item changes. */
@@ -415,32 +425,35 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
415425
* @param origin Action from which the focus originated. Used to set the correct styling.
416426
*/
417427
focusFirstItem(origin: FocusOrigin = 'program'): void {
418-
// Wait for `onStable` to ensure iOS VoiceOver screen reader focuses the first item (#24735).
419-
this._firstItemFocusSubscription?.unsubscribe();
420-
this._firstItemFocusSubscription = this._ngZone.onStable.pipe(take(1)).subscribe(() => {
421-
let menuPanel: HTMLElement | null = null;
422-
423-
if (this._directDescendantItems.length) {
424-
// Because the `mat-menuPanel` is at the DOM insertion point, not inside the overlay, we don't
425-
// have a nice way of getting a hold of the menuPanel panel. We can't use a `ViewChild` either
426-
// because the panel is inside an `ng-template`. We work around it by starting from one of
427-
// the items and walking up the DOM.
428-
menuPanel = this._directDescendantItems.first!._getHostElement().closest('[role="menu"]');
429-
}
430-
431-
// If an item in the menuPanel is already focused, avoid overriding the focus.
432-
if (!menuPanel || !menuPanel.contains(document.activeElement)) {
433-
const manager = this._keyManager;
434-
manager.setFocusOrigin(origin).setFirstItemActive();
428+
// Wait for `afterNextRender` to ensure iOS VoiceOver screen reader focuses the first item (#24735).
429+
this._firstItemFocusRef?.destroy();
430+
this._firstItemFocusRef = afterNextRender(
431+
() => {
432+
let menuPanel: HTMLElement | null = null;
433+
434+
if (this._directDescendantItems.length) {
435+
// Because the `mat-menuPanel` is at the DOM insertion point, not inside the overlay, we don't
436+
// have a nice way of getting a hold of the menuPanel panel. We can't use a `ViewChild` either
437+
// because the panel is inside an `ng-template`. We work around it by starting from one of
438+
// the items and walking up the DOM.
439+
menuPanel = this._directDescendantItems.first!._getHostElement().closest('[role="menu"]');
440+
}
435441

436-
// If there's no active item at this point, it means that all the items are disabled.
437-
// Move focus to the menuPanel panel so keyboard events like Escape still work. Also this will
438-
// give _some_ feedback to screen readers.
439-
if (!manager.activeItem && menuPanel) {
440-
menuPanel.focus();
442+
// If an item in the menuPanel is already focused, avoid overriding the focus.
443+
if (!menuPanel || !menuPanel.contains(document.activeElement)) {
444+
const manager = this._keyManager;
445+
manager.setFocusOrigin(origin).setFirstItemActive();
446+
447+
// If there's no active item at this point, it means that all the items are disabled.
448+
// Move focus to the menuPanel panel so keyboard events like Escape still work. Also this will
449+
// give _some_ feedback to screen readers.
450+
if (!manager.activeItem && menuPanel) {
451+
menuPanel.focus();
452+
}
441453
}
442-
}
443-
});
454+
},
455+
{injector: this._injector},
456+
);
444457
}
445458

446459
/**

0 commit comments

Comments
 (0)