Skip to content

Commit 18776bc

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 5575673 commit 18776bc

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
@@ -55,12 +55,6 @@
5555
text-decoration: none;
5656

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

@@ -86,6 +80,11 @@
8680
}
8781
}
8882

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

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

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

608+
it('should prevent the default action when clicking on a disabled item', () => {
609+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
610+
fixture.detectChanges();
611+
fixture.componentInstance.trigger.openMenu();
612+
fixture.detectChanges();
613+
614+
const item = overlayContainerElement.querySelector('.mat-mdc-menu-item[disabled]')!;
615+
const itemEvent = dispatchFakeEvent(item, 'click');
616+
fixture.detectChanges();
617+
expect(itemEvent.defaultPrevented).toBe(true);
618+
619+
const contentWrapper = item.querySelector('span')!;
620+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
621+
fixture.detectChanges();
622+
expect(wrapperEvent.defaultPrevented).toBe(true);
623+
});
624+
608625
it('should be able to set an alternate role on the menu items', () => {
609626
const fixture = createComponent(MenuWithCheckboxItems);
610627
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, CanDisableCtor,
@@ -63,6 +65,9 @@ export class MatMenuItem extends _MatMenuItemMixinBase
6365
/** ARIA role for the menu item. */
6466
@Input() role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox' = 'menuitem';
6567

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

@@ -83,9 +88,11 @@ export class MatMenuItem extends _MatMenuItemMixinBase
8388
*/
8489
@Inject(DOCUMENT) _document?: any,
8590
private _focusMonitor?: FocusMonitor,
86-
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>) {
91+
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
92+
private _ngZone?: NgZone) {
8793

8894
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
95+
// @breaking-change 11.0.0 make `_ngZone` a required parameter.
8996
super();
9097

9198
if (_parentMenu && _parentMenu.addItem) {
@@ -111,6 +118,13 @@ export class MatMenuItem extends _MatMenuItemMixinBase
111118
// mouse or touch interaction.
112119
this._focusMonitor.monitor(this._elementRef, false);
113120
}
121+
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+
}
114128
}
115129

116130
ngOnDestroy() {
@@ -122,6 +136,11 @@ export class MatMenuItem extends _MatMenuItemMixinBase
122136
this._parentMenu.removeItem(this);
123137
}
124138

139+
this._elementRef.nativeElement.removeEventListener('click', this._preventDisabledClicks);
140+
if (this._content) {
141+
this._content.nativeElement.removeEventListener('click', this._preventDisabledClicks);
142+
}
143+
125144
this._hovered.complete();
126145
this._focused.complete();
127146
}
@@ -136,20 +155,6 @@ export class MatMenuItem extends _MatMenuItemMixinBase
136155
return this._elementRef.nativeElement;
137156
}
138157

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

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

src/material/menu/menu.scss

Lines changed: 0 additions & 9 deletions
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

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

606+
it('should prevent the default action when clicking on a disabled item', () => {
607+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
608+
fixture.detectChanges();
609+
fixture.componentInstance.trigger.openMenu();
610+
fixture.detectChanges();
611+
612+
const item = overlayContainerElement.querySelector('.mat-menu-item[disabled]')!;
613+
const itemEvent = dispatchFakeEvent(item, 'click');
614+
fixture.detectChanges();
615+
expect(itemEvent.defaultPrevented).toBe(true);
616+
617+
const contentWrapper = item.querySelector('span')!;
618+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
619+
fixture.detectChanges();
620+
expect(wrapperEvent.defaultPrevented).toBe(true);
621+
});
622+
606623
it('should be able to set an alternate role on the menu items', () => {
607624
const fixture = createComponent(MenuWithCheckboxItems);
608625
fixture.detectChanges();

tools/public_api_guard/material/menu.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,15 @@ export interface MatMenuDefaultOptions {
9898
}
9999

100100
export declare class MatMenuItem extends _MatMenuItemMixinBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
101+
_content: ElementRef<HTMLElement> | undefined;
101102
readonly _focused: Subject<MatMenuItem>;
102103
_highlighted: boolean;
103104
readonly _hovered: Subject<MatMenuItem>;
104105
_parentMenu?: MatMenuPanel<MatMenuItem> | undefined;
105106
_triggersSubmenu: boolean;
106107
role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox';
107108
constructor(_elementRef: ElementRef<HTMLElement>,
108-
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined);
109-
_checkDisabled(event: Event): void;
109+
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined, _ngZone?: NgZone | undefined);
110110
_getHostElement(): HTMLElement;
111111
_getTabIndex(): string;
112112
_handleMouseEnter(): void;
@@ -117,7 +117,7 @@ export declare class MatMenuItem extends _MatMenuItemMixinBase implements Focusa
117117
static ngAcceptInputType_disableRipple: BooleanInput;
118118
static ngAcceptInputType_disabled: BooleanInput;
119119
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "role": "role"; }, {}, never, ["*"]>;
120-
static ɵfac: i0.ɵɵFactoryDef<MatMenuItem, [null, null, null, { optional: true; }]>;
120+
static ɵfac: i0.ɵɵFactoryDef<MatMenuItem, [null, null, null, { optional: true; }, null]>;
121121
}
122122

123123
export declare class MatMenuModule {

0 commit comments

Comments
 (0)