Skip to content

Commit 7cc3eb4

Browse files
committed
fix(material/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 249201b commit 7cc3eb4

File tree

8 files changed

+84
-35
lines changed

8 files changed

+84
-35
lines changed

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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,6 @@ mat-menu {
6161
text-decoration: none;
6262

6363
&[disabled] {
64-
// Usually every click inside the menu closes it, however some browsers will stop events
65-
// when the user clicks on a disabled item, **except** when the user clicks on a non-disabled
66-
// child node of the disabled button. This is inconsistent because some regions of a disabled
67-
// button will still cause the menu to close and some won't (see #16694). We make the behavior
68-
// more consistent by disabling pointer events and allowing the user to click through.
69-
pointer-events: none;
7064
cursor: default;
7165
}
7266

@@ -98,6 +92,11 @@ mat-menu {
9892
}
9993
}
10094

95+
.mat-mdc-menu-item-content {
96+
display: flex;
97+
align-items: center;
98+
}
99+
101100
// Renders out a chevron on menu items that trigger a sub-menu.
102101
.mat-mdc-menu-item-submenu-trigger {
103102
@include menu-common.item-submenu-trigger(mdc-list.$deprecated-side-padding);

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,23 @@ describe('MDC-based MatMenu', () => {
613613
expect(items.every(item => item.getAttribute('role') === 'menuitem')).toBe(true);
614614
});
615615

616+
it('should prevent the default action when clicking on a disabled item', () => {
617+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
618+
fixture.detectChanges();
619+
fixture.componentInstance.trigger.openMenu();
620+
fixture.detectChanges();
621+
622+
const item = overlayContainerElement.querySelector('.mat-mdc-menu-item[disabled]')!;
623+
const itemEvent = dispatchFakeEvent(item, 'click');
624+
fixture.detectChanges();
625+
expect(itemEvent.defaultPrevented).toBe(true);
626+
627+
const contentWrapper = item.querySelector('span')!;
628+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
629+
fixture.detectChanges();
630+
expect(wrapperEvent.defaultPrevented).toBe(true);
631+
});
632+
616633
it('should be able to set an alternate role on the menu items', () => {
617634
const fixture = createComponent(MenuWithCheckboxItems);
618635
fixture.detectChanges();

src/material/menu/menu-item.html

Lines changed: 1 addition & 1 deletion
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

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
Optional,
1919
Input,
2020
HostListener,
21+
NgZone,
2122
AfterViewInit,
23+
ViewChild,
2224
} from '@angular/core';
2325
import {
2426
CanDisable,
@@ -61,6 +63,9 @@ export class MatMenuItem extends _MatMenuItemBase
6163
/** ARIA role for the menu item. */
6264
@Input() role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox' = 'menuitem';
6365

66+
/** Reference to the element wrapping the projected content. */
67+
@ViewChild('content') _content: ElementRef<HTMLElement> | undefined;
68+
6469
/** Stream that emits when the menu item is hovered. */
6570
readonly _hovered: Subject<MatMenuItem> = new Subject<MatMenuItem>();
6671

@@ -81,9 +86,11 @@ export class MatMenuItem extends _MatMenuItemBase
8186
*/
8287
@Inject(DOCUMENT) _document?: any,
8388
private _focusMonitor?: FocusMonitor,
84-
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>) {
89+
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
90+
private _ngZone?: NgZone) {
8591

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

8996
if (_parentMenu && _parentMenu.addItem) {
@@ -109,6 +116,13 @@ export class MatMenuItem extends _MatMenuItemBase
109116
// mouse or touch interaction.
110117
this._focusMonitor.monitor(this._elementRef, false);
111118
}
119+
120+
// @breaking-change 11.0.0 Remove null check for `_ngZone`.
121+
if (this._ngZone) {
122+
this._ngZone.runOutsideAngular(() => this._bindDisabledClickEvents());
123+
} else {
124+
this._bindDisabledClickEvents();
125+
}
112126
}
113127

114128
ngOnDestroy() {
@@ -120,6 +134,11 @@ export class MatMenuItem extends _MatMenuItemBase
120134
this._parentMenu.removeItem(this);
121135
}
122136

137+
this._elementRef.nativeElement.removeEventListener('click', this._preventDisabledClicks);
138+
if (this._content) {
139+
this._content.nativeElement.removeEventListener('click', this._preventDisabledClicks);
140+
}
141+
123142
this._hovered.complete();
124143
this._focused.complete();
125144
}
@@ -134,20 +153,6 @@ export class MatMenuItem extends _MatMenuItemBase
134153
return this._elementRef.nativeElement;
135154
}
136155

137-
/** Prevents the default element actions if it is disabled. */
138-
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
139-
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
140-
// ViewEngine they're overwritten.
141-
// TODO(crisbeto): we move this back into `host` once Ivy is turned on by default.
142-
// tslint:disable-next-line:no-host-decorator-in-concrete
143-
@HostListener('click', ['$event'])
144-
_checkDisabled(event: Event): void {
145-
if (this.disabled) {
146-
event.preventDefault();
147-
event.stopPropagation();
148-
}
149-
}
150-
151156
/** Emits to the hover stream. */
152157
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
153158
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
@@ -173,6 +178,26 @@ export class MatMenuItem extends _MatMenuItemBase
173178
return clone.textContent?.trim() || '';
174179
}
175180

181+
/** Binds the click events that prevent the default actions while disabled. */
182+
private _bindDisabledClickEvents() {
183+
// We need to bind this event both on the root node and the content wrapper, because browsers
184+
// won't dispatch events on disabled `button` nodes, but they'll still be dispatched if the
185+
// user interacts with a non-disabled child of the button. This means that can get regions
186+
// inside a disabled menu item where clicks land and others where they don't.
187+
this._elementRef.nativeElement.addEventListener('click', this._preventDisabledClicks);
188+
if (this._content) {
189+
this._content.nativeElement.addEventListener('click', this._preventDisabledClicks);
190+
}
191+
}
192+
193+
/** Prevents the default click action if the menu item is disabled. */
194+
private _preventDisabledClicks = (event: Event) => {
195+
if (this.disabled) {
196+
event.preventDefault();
197+
event.stopPropagation();
198+
}
199+
}
200+
176201
static ngAcceptInputType_disabled: BooleanInput;
177202
static ngAcceptInputType_disableRipple: BooleanInput;
178203
}

src/material/menu/menu.scss

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,6 @@ mat-menu {
4949
@include menu-common.item-base();
5050
position: relative;
5151

52-
&[disabled] {
53-
// Usually every click inside the menu closes it, however some browsers will stop events
54-
// when the user clicks on a disabled item, **except** when the user clicks on a non-disabled
55-
// child node of the disabled button. This is inconsistent because some regions of a disabled
56-
// button will still cause the menu to close and some won't (see #16694). We make the behavior
57-
// more consistent by disabling pointer events and allowing the user to click through.
58-
pointer-events: none;
59-
}
60-
6152
@include a11y.high-contrast(active, off) {
6253
$outline-width: 1px;
6354

src/material/menu/menu.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,23 @@ describe('MatMenu', () => {
612612
expect(items.every(item => item.getAttribute('role') === 'menuitem')).toBe(true);
613613
});
614614

615+
it('should prevent the default action when clicking on a disabled item', () => {
616+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
617+
fixture.detectChanges();
618+
fixture.componentInstance.trigger.openMenu();
619+
fixture.detectChanges();
620+
621+
const item = overlayContainerElement.querySelector('.mat-menu-item[disabled]')!;
622+
const itemEvent = dispatchFakeEvent(item, 'click');
623+
fixture.detectChanges();
624+
expect(itemEvent.defaultPrevented).toBe(true);
625+
626+
const contentWrapper = item.querySelector('span')!;
627+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
628+
fixture.detectChanges();
629+
expect(wrapperEvent.defaultPrevented).toBe(true);
630+
});
631+
615632
it('should be able to set an alternate role on the menu items', () => {
616633
const fixture = createComponent(MenuWithCheckboxItems);
617634
fixture.detectChanges();

tools/public_api_guard/material/menu.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,15 @@ export interface MatMenuDefaultOptions {
103103
}
104104

105105
export declare class MatMenuItem extends _MatMenuItemBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
106+
_content: ElementRef<HTMLElement> | undefined;
106107
readonly _focused: Subject<MatMenuItem>;
107108
_highlighted: boolean;
108109
readonly _hovered: Subject<MatMenuItem>;
109110
_parentMenu?: MatMenuPanel<MatMenuItem> | undefined;
110111
_triggersSubmenu: boolean;
111112
role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox';
112113
constructor(_elementRef: ElementRef<HTMLElement>,
113-
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined);
114-
_checkDisabled(event: Event): void;
114+
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined, _ngZone?: NgZone | undefined);
115115
_getHostElement(): HTMLElement;
116116
_getTabIndex(): string;
117117
_handleMouseEnter(): void;
@@ -122,7 +122,7 @@ export declare class MatMenuItem extends _MatMenuItemBase implements FocusableOp
122122
static ngAcceptInputType_disableRipple: BooleanInput;
123123
static ngAcceptInputType_disabled: BooleanInput;
124124
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "role": "role"; }, {}, never, ["*"]>;
125-
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }]>;
125+
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null]>;
126126
}
127127

128128
export declare class MatMenuModule {

0 commit comments

Comments
 (0)