Skip to content

Commit 41ff5ad

Browse files
committed
fix(menu): clicks on disabled item closing the menu
We have some logic to prevent clicks on disabled items from closing the menu. The problem is that browsers swallow clicks and don't fire their event listeners for disabled `button` nodes, but not any non-disabled child nodes. In #16696 we made it consistent so clicks don't land on any of the button's elements, but these changes fix the underlying issue by binding the event both on the root `button`, and a wrapper `span` that's around the content. This way we're guaranteed to hit at least one of the listeners. These changes also move the event outside the `NgZone` since it doesn't trigger any changes in the view. Fixes #19173.
1 parent 4136a70 commit 41ff5ad

File tree

8 files changed

+92
-38
lines changed

8 files changed

+92
-38
lines changed

src/material-experimental/mdc-menu/menu-item.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<ng-content></ng-content>
1+
<span class="mat-mdc-menu-item-content" #content><ng-content></ng-content></span>
22
<div class="mat-mdc-menu-ripple" matRipple
33
[matRippleDisabled]="disableRipple || disabled"
44
[matRippleTrigger]="_getHostElement()">

src/material-experimental/mdc-menu/menu.scss

+5-6
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,6 @@
5353
text-decoration: none;
5454

5555
&[disabled] {
56-
// Usually every click inside the menu closes it, however some browsers will stop events
57-
// when the user clicks on a disabled item, **except** when the user clicks on a non-disabled
58-
// child node of the disabled button. This is inconsistent because some regions of a disabled
59-
// button will still cause the menu to close and some won't (see #16694). We make the behavior
60-
// more consistent by disabling pointer events and allowing the user to click through.
61-
pointer-events: none;
6256
cursor: default;
6357
}
6458

@@ -84,6 +78,11 @@
8478
}
8579
}
8680

81+
.mat-mdc-menu-item-content {
82+
display: flex;
83+
align-items: center;
84+
}
85+
8786
// Renders out a chevron on menu items that trigger a sub-menu.
8887
.mat-mdc-menu-item-submenu-trigger {
8988
@include mat-menu-item-submenu-trigger($mdc-list-side-padding);

src/material-experimental/mdc-menu/menu.spec.ts

+17
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,23 @@ describe('MDC-based MatMenu', () => {
569569
expect(items.every(item => item.getAttribute('role') === 'menuitem')).toBe(true);
570570
});
571571

572+
it('should prevent the default action when clicking on a disabled item', () => {
573+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
574+
fixture.detectChanges();
575+
fixture.componentInstance.trigger.openMenu();
576+
fixture.detectChanges();
577+
578+
const item = overlayContainerElement.querySelector('.mat-mdc-menu-item[disabled]')!;
579+
const itemEvent = dispatchFakeEvent(item, 'click');
580+
fixture.detectChanges();
581+
expect(itemEvent.defaultPrevented).toBe(true);
582+
583+
const contentWrapper = item.querySelector('span')!;
584+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
585+
fixture.detectChanges();
586+
expect(wrapperEvent.defaultPrevented).toBe(true);
587+
});
588+
572589
it('should be able to set an alternate role on the menu items', () => {
573590
const fixture = createComponent(MenuWithCheckboxItems);
574591
fixture.detectChanges();

src/material/menu/menu-item.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<ng-content></ng-content>
1+
<span #content><ng-content></ng-content></span>
22
<div class="mat-menu-ripple" matRipple
33
[matRippleDisabled]="disableRipple || disabled"
44
[matRippleTrigger]="_getHostElement()">

src/material/menu/menu-item.ts

+46-17
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {
1818
Optional,
1919
Input,
2020
HostListener,
21+
NgZone,
22+
AfterViewInit,
23+
ViewChild,
2124
} from '@angular/core';
2225
import {
2326
CanDisable, CanDisableCtor,
@@ -57,11 +60,14 @@ const _MatMenuItemMixinBase: CanDisableRippleCtor & CanDisableCtor & typeof MatM
5760
templateUrl: 'menu-item.html',
5861
})
5962
export class MatMenuItem extends _MatMenuItemMixinBase
60-
implements FocusableOption, CanDisable, CanDisableRipple, OnDestroy {
63+
implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
6164

6265
/** ARIA role for the menu item. */
6366
@Input() role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox' = 'menuitem';
6467

68+
/** Reference to the element wrapping the projected content. */
69+
@ViewChild('content') _content: ElementRef<HTMLElement> | undefined;
70+
6571
private _document: Document;
6672

6773
/** Stream that emits when the menu item is hovered. */
@@ -80,9 +86,11 @@ export class MatMenuItem extends _MatMenuItemMixinBase
8086
private _elementRef: ElementRef<HTMLElement>,
8187
@Inject(DOCUMENT) document?: any,
8288
private _focusMonitor?: FocusMonitor,
83-
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>) {
89+
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
90+
private _ngZone?: NgZone) {
8491

8592
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
93+
// @breaking-change 11.0.0 make `_ngZone` a required parameter.
8694
super();
8795

8896
if (_focusMonitor) {
@@ -110,6 +118,15 @@ export class MatMenuItem extends _MatMenuItemMixinBase
110118
this._focused.next(this);
111119
}
112120

121+
ngAfterViewInit() {
122+
// @breaking-change 11.0.0 Remove null check for `_ngZone`.
123+
if (this._ngZone) {
124+
this._ngZone.runOutsideAngular(() => this._bindDisabledClickEvents());
125+
} else {
126+
this._bindDisabledClickEvents();
127+
}
128+
}
129+
113130
ngOnDestroy() {
114131
if (this._focusMonitor) {
115132
this._focusMonitor.stopMonitoring(this._elementRef);
@@ -119,6 +136,11 @@ export class MatMenuItem extends _MatMenuItemMixinBase
119136
this._parentMenu.removeItem(this);
120137
}
121138

139+
this._elementRef.nativeElement.removeEventListener('click', this._preventDisabledClicks);
140+
if (this._content) {
141+
this._content.nativeElement.removeEventListener('click', this._preventDisabledClicks);
142+
}
143+
122144
this._hovered.complete();
123145
this._focused.complete();
124146
}
@@ -133,20 +155,6 @@ export class MatMenuItem extends _MatMenuItemMixinBase
133155
return this._elementRef.nativeElement;
134156
}
135157

136-
/** Prevents the default element actions if it is disabled. */
137-
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
138-
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
139-
// ViewEngine they're overwritten.
140-
// TODO(crisbeto): we move this back into `host` once Ivy is turned on by default.
141-
// tslint:disable-next-line:no-host-decorator-in-concrete
142-
@HostListener('click', ['$event'])
143-
_checkDisabled(event: Event): void {
144-
if (this.disabled) {
145-
event.preventDefault();
146-
event.stopPropagation();
147-
}
148-
}
149-
150158
/** Emits to the hover stream. */
151159
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
152160
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
@@ -160,7 +168,8 @@ export class MatMenuItem extends _MatMenuItemMixinBase
160168

161169
/** Gets the label to be used when determining whether the option should be focused. */
162170
getLabel(): string {
163-
const element: HTMLElement = this._elementRef.nativeElement;
171+
const element: HTMLElement = this._content ?
172+
this._content.nativeElement : this._elementRef.nativeElement;
164173
const textNodeType = this._document ? this._document.TEXT_NODE : 3;
165174
let output = '';
166175

@@ -180,6 +189,26 @@ export class MatMenuItem extends _MatMenuItemMixinBase
180189
return output.trim();
181190
}
182191

192+
/** Binds the click events that prevent the default actions while disabled. */
193+
private _bindDisabledClickEvents() {
194+
// We need to bind this event both on the root node and the content wrapper, because browsers
195+
// won't dispatch events on disabled `button` nodes, but they'll still be dispatched if the
196+
// user interacts with a non-disabled child of the button. This means that can get regions
197+
// inside a disabled menu item where clicks land and others where they don't.
198+
this._elementRef.nativeElement.addEventListener('click', this._preventDisabledClicks);
199+
if (this._content) {
200+
this._content.nativeElement.addEventListener('click', this._preventDisabledClicks);
201+
}
202+
}
203+
204+
/** Prevents the default click action if the menu item is disabled. */
205+
private _preventDisabledClicks = (event: Event) => {
206+
if (this.disabled) {
207+
event.preventDefault();
208+
event.stopPropagation();
209+
}
210+
}
211+
183212
static ngAcceptInputType_disabled: BooleanInput;
184213
static ngAcceptInputType_disableRipple: BooleanInput;
185214
}

src/material/menu/menu.scss

-9
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,6 @@ $mat-menu-submenu-indicator-size: 10px !default;
4444
@include mat-menu-item-base();
4545
position: relative;
4646

47-
&[disabled] {
48-
// Usually every click inside the menu closes it, however some browsers will stop events
49-
// when the user clicks on a disabled item, **except** when the user clicks on a non-disabled
50-
// child node of the disabled button. This is inconsistent because some regions of a disabled
51-
// button will still cause the menu to close and some won't (see #16694). We make the behavior
52-
// more consistent by disabling pointer events and allowing the user to click through.
53-
pointer-events: none;
54-
}
55-
5647
@include cdk-high-contrast(active, off) {
5748
&.cdk-program-focused,
5849
&.cdk-keyboard-focused,

src/material/menu/menu.spec.ts

+17
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,23 @@ describe('MatMenu', () => {
569569
expect(items.every(item => item.getAttribute('role') === 'menuitem')).toBe(true);
570570
});
571571

572+
it('should prevent the default action when clicking on a disabled item', () => {
573+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
574+
fixture.detectChanges();
575+
fixture.componentInstance.trigger.openMenu();
576+
fixture.detectChanges();
577+
578+
const item = overlayContainerElement.querySelector('.mat-menu-item[disabled]')!;
579+
const itemEvent = dispatchFakeEvent(item, 'click');
580+
fixture.detectChanges();
581+
expect(itemEvent.defaultPrevented).toBe(true);
582+
583+
const contentWrapper = item.querySelector('span')!;
584+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
585+
fixture.detectChanges();
586+
expect(wrapperEvent.defaultPrevented).toBe(true);
587+
});
588+
572589
it('should be able to set an alternate role on the menu items', () => {
573590
const fixture = createComponent(MenuWithCheckboxItems);
574591
fixture.detectChanges();

tools/public_api_guard/material/menu.d.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -98,25 +98,26 @@ export interface MatMenuDefaultOptions {
9898
yPosition: MenuPositionY;
9999
}
100100

101-
export declare class MatMenuItem extends _MatMenuItemMixinBase implements FocusableOption, CanDisable, CanDisableRipple, OnDestroy {
101+
export declare class MatMenuItem extends _MatMenuItemMixinBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
102+
_content: ElementRef<HTMLElement> | undefined;
102103
readonly _focused: Subject<MatMenuItem>;
103104
_highlighted: boolean;
104105
readonly _hovered: Subject<MatMenuItem>;
105106
_parentMenu?: MatMenuPanel<MatMenuItem> | undefined;
106107
_triggersSubmenu: boolean;
107108
role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox';
108-
constructor(_elementRef: ElementRef<HTMLElement>, document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined);
109-
_checkDisabled(event: Event): void;
109+
constructor(_elementRef: ElementRef<HTMLElement>, document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined, _ngZone?: NgZone | undefined);
110110
_getHostElement(): HTMLElement;
111111
_getTabIndex(): string;
112112
_handleMouseEnter(): void;
113113
focus(origin?: FocusOrigin, options?: FocusOptions): void;
114114
getLabel(): string;
115+
ngAfterViewInit(): void;
115116
ngOnDestroy(): void;
116117
static ngAcceptInputType_disableRipple: BooleanInput;
117118
static ngAcceptInputType_disabled: BooleanInput;
118119
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "role": "role"; }, {}, never, ["*"]>;
119-
static ɵfac: i0.ɵɵFactoryDef<MatMenuItem, [null, null, null, { optional: true; }]>;
120+
static ɵfac: i0.ɵɵFactoryDef<MatMenuItem, [null, null, null, { optional: true; }, null]>;
120121
}
121122

122123
export declare class MatMenuModule {

0 commit comments

Comments
 (0)