Skip to content

Commit b361c97

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 03485cd commit b361c97

File tree

8 files changed

+83
-28
lines changed

8 files changed

+83
-28
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<ng-content select="mat-icon"></ng-content>
2-
<span class="mdc-list-item__primary-text"><ng-content></ng-content></span>
2+
<span class="mdc-list-item__primary-text mat-mdc-menu-item-content" #content><ng-content></ng-content></span>
33
<div class="mat-mdc-menu-ripple" matRipple
44
[matRippleDisabled]="disableRipple || disabled"
55
[matRippleTrigger]="_getHostElement()">

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

+5-6
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,6 @@ mat-menu {
6060
min-height: map.get($height-config, default);
6161

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

7165
// This is the same as `mdc-list-mixins.list-disabled-opacity` which
@@ -107,6 +101,11 @@ mat-menu {
107101
}
108102
}
109103

104+
.mat-mdc-menu-item-content {
105+
display: flex;
106+
align-items: center;
107+
}
108+
110109
.mat-mdc-menu-item-submenu-trigger {
111110
@include menu-common.item-submenu-trigger(mdc-list-variables.$side-padding);
112111
}

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

+17
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,23 @@ describe('MDC-based MatMenu', () => {
645645
expect(items.every(item => item.getAttribute('role') === 'menuitemcheckbox')).toBe(true);
646646
}));
647647

648+
it('should prevent the default action when clicking on a disabled item', () => {
649+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
650+
fixture.detectChanges();
651+
fixture.componentInstance.trigger.openMenu();
652+
fixture.detectChanges();
653+
654+
const item = overlayContainerElement.querySelector('.mat-mdc-menu-item[disabled]')!;
655+
const itemEvent = dispatchFakeEvent(item, 'click');
656+
fixture.detectChanges();
657+
expect(itemEvent.defaultPrevented).toBe(true);
658+
659+
const contentWrapper = item.querySelector('span')!;
660+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
661+
fixture.detectChanges();
662+
expect(wrapperEvent.defaultPrevented).toBe(true);
663+
});
664+
648665
it('should not change focus origin if origin not specified for menu items', fakeAsync(() => {
649666
const fixture = createComponent(MenuWithCheckboxItems);
650667
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

+39-8
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {
1616
Inject,
1717
Optional,
1818
Input,
19+
NgZone,
1920
AfterViewInit,
2021
ChangeDetectorRef,
22+
ViewChild,
2123
} from '@angular/core';
2224
import {
2325
CanDisable,
@@ -63,6 +65,9 @@ export class MatMenuItem
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

@@ -84,13 +89,15 @@ export class MatMenuItem
8489
@Inject(DOCUMENT) _document?: any,
8590
private _focusMonitor?: FocusMonitor,
8691
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
92+
private _ngZone?: NgZone,
8793
/**
8894
* @deprecated `_changeDetectorRef` to become a required parameter.
8995
* @breaking-change 14.0.0
9096
*/
9197
private _changeDetectorRef?: ChangeDetectorRef,
9298
) {
9399
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
100+
// @breaking-change 11.0.0 make `_ngZone` a required parameter.
94101
super();
95102

96103
if (_parentMenu && _parentMenu.addItem) {
@@ -116,6 +123,13 @@ export class MatMenuItem
116123
// mouse or touch interaction.
117124
this._focusMonitor.monitor(this._elementRef, false);
118125
}
126+
127+
// @breaking-change 11.0.0 Remove null check for `_ngZone`.
128+
if (this._ngZone) {
129+
this._ngZone.runOutsideAngular(() => this._bindDisabledClickEvents());
130+
} else {
131+
this._bindDisabledClickEvents();
132+
}
119133
}
120134

121135
ngOnDestroy() {
@@ -127,6 +141,11 @@ export class MatMenuItem
127141
this._parentMenu.removeItem(this);
128142
}
129143

144+
this._elementRef.nativeElement.removeEventListener('click', this._preventDisabledClicks);
145+
if (this._content) {
146+
this._content.nativeElement.removeEventListener('click', this._preventDisabledClicks);
147+
}
148+
130149
this._hovered.complete();
131150
this._focused.complete();
132151
}
@@ -141,14 +160,6 @@ export class MatMenuItem
141160
return this._elementRef.nativeElement;
142161
}
143162

144-
/** Prevents the default element actions if it is disabled. */
145-
_checkDisabled(event: Event): void {
146-
if (this.disabled) {
147-
event.preventDefault();
148-
event.stopPropagation();
149-
}
150-
}
151-
152163
/** Emits to the hover stream. */
153164
_handleMouseEnter() {
154165
this._hovered.next(this);
@@ -175,4 +186,24 @@ export class MatMenuItem
175186
this._highlighted = isHighlighted;
176187
this._changeDetectorRef?.markForCheck();
177188
}
189+
190+
/** Binds the click events that prevent the default actions while disabled. */
191+
private _bindDisabledClickEvents() {
192+
// We need to bind this event both on the root node and the content wrapper, because browsers
193+
// won't dispatch events on disabled `button` nodes, but they'll still be dispatched if the
194+
// user interacts with a non-disabled child of the button. This means that can get regions
195+
// inside a disabled menu item where clicks land and others where they don't.
196+
this._elementRef.nativeElement.addEventListener('click', this._preventDisabledClicks);
197+
if (this._content) {
198+
this._content.nativeElement.addEventListener('click', this._preventDisabledClicks);
199+
}
200+
}
201+
202+
/** Prevents the default click action if the menu item is disabled. */
203+
private _preventDisabledClicks = (event: Event) => {
204+
if (this.disabled) {
205+
event.preventDefault();
206+
event.stopPropagation();
207+
}
208+
};
178209
}

src/material/menu/menu.scss

-9
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

+17
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,23 @@ describe('MatMenu', () => {
644644
expect(items.every(item => item.getAttribute('role') === 'menuitemcheckbox')).toBe(true);
645645
}));
646646

647+
it('should prevent the default action when clicking on a disabled item', () => {
648+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
649+
fixture.detectChanges();
650+
fixture.componentInstance.trigger.openMenu();
651+
fixture.detectChanges();
652+
653+
const item = overlayContainerElement.querySelector('.mat-menu-item[disabled]')!;
654+
const itemEvent = dispatchFakeEvent(item, 'click');
655+
fixture.detectChanges();
656+
expect(itemEvent.defaultPrevented).toBe(true);
657+
658+
const contentWrapper = item.querySelector('span')!;
659+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
660+
fixture.detectChanges();
661+
expect(wrapperEvent.defaultPrevented).toBe(true);
662+
});
663+
647664
it('should not change focus origin if origin not specified for menu items', fakeAsync(() => {
648665
const fixture = createComponent(MenuWithCheckboxItems);
649666
fixture.detectChanges();

tools/public_api_guard/material/menu.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,9 @@ export interface MatMenuDefaultOptions {
192192
// @public
193193
export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
194194
constructor(_elementRef: ElementRef<HTMLElement>,
195-
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined,
195+
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined, _ngZone?: NgZone | undefined,
196196
_changeDetectorRef?: ChangeDetectorRef | undefined);
197-
_checkDisabled(event: Event): void;
197+
_content: ElementRef<HTMLElement> | undefined;
198198
focus(origin?: FocusOrigin, options?: FocusOptions): void;
199199
readonly _focused: Subject<MatMenuItem>;
200200
_getHostElement(): HTMLElement;
@@ -216,7 +216,7 @@ export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, Ca
216216
// (undocumented)
217217
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "role": "role"; }, {}, never, ["*"]>;
218218
// (undocumented)
219-
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null]>;
219+
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null, null]>;
220220
}
221221

222222
// @public (undocumented)

0 commit comments

Comments
 (0)