Skip to content

Commit 2a62ba4

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 b17ed9d commit 2a62ba4

File tree

9 files changed

+87
-36
lines changed

9 files changed

+87
-36
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,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-item.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ElementRef,
1515
Optional,
1616
ChangeDetectorRef,
17+
NgZone,
1718
} from '@angular/core';
1819
import {
1920
MAT_RIPPLE_GLOBAL_OPTIONS,
@@ -64,8 +65,9 @@ export class MatMenuItem extends BaseMatMenuItem {
6465
@Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS)
6566
globalRippleOptions?: RippleGlobalOptions,
6667
@Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string,
68+
ngZone?: NgZone,
6769
changeDetectorRef?: ChangeDetectorRef) {
68-
super(elementRef, document, focusMonitor, parentMenu, changeDetectorRef);
70+
super(elementRef, document, focusMonitor, parentMenu, ngZone, changeDetectorRef);
6971

7072
this._noopAnimations = animationMode === 'NoopAnimations';
7173
this._rippleAnimation = globalRippleOptions?.animation || {

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

Lines changed: 5 additions & 6 deletions
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

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

642+
it('should prevent the default action when clicking on a disabled item', () => {
643+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
644+
fixture.detectChanges();
645+
fixture.componentInstance.trigger.openMenu();
646+
fixture.detectChanges();
647+
648+
const item = overlayContainerElement.querySelector('.mat-mdc-menu-item[disabled]')!;
649+
const itemEvent = dispatchFakeEvent(item, 'click');
650+
fixture.detectChanges();
651+
expect(itemEvent.defaultPrevented).toBe(true);
652+
653+
const contentWrapper = item.querySelector('span')!;
654+
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
655+
fixture.detectChanges();
656+
expect(wrapperEvent.defaultPrevented).toBe(true);
657+
});
658+
642659
it('should not change focus origin if origin not specified for menu items', fakeAsync(() => {
643660
const fixture = createComponent(MenuWithCheckboxItems);
644661
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,8 +18,10 @@ import {
1818
Optional,
1919
Input,
2020
HostListener,
21+
NgZone,
2122
AfterViewInit,
2223
ChangeDetectorRef,
24+
ViewChild,
2325
} from '@angular/core';
2426
import {
2527
CanDisable,
@@ -62,6 +64,9 @@ export class MatMenuItem extends _MatMenuItemBase
6264
/** ARIA role for the menu item. */
6365
@Input() role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox' = 'menuitem';
6466

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

@@ -83,13 +88,15 @@ export class MatMenuItem extends _MatMenuItemBase
8388
@Inject(DOCUMENT) _document?: any,
8489
private _focusMonitor?: FocusMonitor,
8590
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
91+
private _ngZone?: NgZone,
8692
/**
8793
* @deprecated `_changeDetectorRef` to become a required parameter.
8894
* @breaking-change 14.0.0
8995
*/
90-
private _changeDetectorRef?: ChangeDetectorRef) {
96+
private _changeDetectorRef?: ChangeDetectorRef) {
9197

9298
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
99+
// @breaking-change 11.0.0 make `_ngZone` a required parameter.
93100
super();
94101

95102
if (_parentMenu && _parentMenu.addItem) {
@@ -115,6 +122,13 @@ export class MatMenuItem extends _MatMenuItemBase
115122
// mouse or touch interaction.
116123
this._focusMonitor.monitor(this._elementRef, false);
117124
}
125+
126+
// @breaking-change 11.0.0 Remove null check for `_ngZone`.
127+
if (this._ngZone) {
128+
this._ngZone.runOutsideAngular(() => this._bindDisabledClickEvents());
129+
} else {
130+
this._bindDisabledClickEvents();
131+
}
118132
}
119133

120134
ngOnDestroy() {
@@ -126,6 +140,11 @@ export class MatMenuItem extends _MatMenuItemBase
126140
this._parentMenu.removeItem(this);
127141
}
128142

143+
this._elementRef.nativeElement.removeEventListener('click', this._preventDisabledClicks);
144+
if (this._content) {
145+
this._content.nativeElement.removeEventListener('click', this._preventDisabledClicks);
146+
}
147+
129148
this._hovered.complete();
130149
this._focused.complete();
131150
}
@@ -140,20 +159,6 @@ export class MatMenuItem extends _MatMenuItemBase
140159
return this._elementRef.nativeElement;
141160
}
142161

143-
/** Prevents the default element actions if it is disabled. */
144-
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
145-
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
146-
// ViewEngine they're overwritten.
147-
// TODO(crisbeto): we move this back into `host` once Ivy is turned on by default.
148-
// tslint:disable-next-line:no-host-decorator-in-concrete
149-
@HostListener('click', ['$event'])
150-
_checkDisabled(event: Event): void {
151-
if (this.disabled) {
152-
event.preventDefault();
153-
event.stopPropagation();
154-
}
155-
}
156-
157162
/** Emits to the hover stream. */
158163
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
159164
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
@@ -188,6 +193,26 @@ export class MatMenuItem extends _MatMenuItemBase
188193
this._changeDetectorRef?.markForCheck();
189194
}
190195

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

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
@@ -639,6 +639,23 @@ describe('MatMenu', () => {
639639
expect(items.every(item => item.getAttribute('role') === 'menuitemcheckbox')).toBe(true);
640640
}));
641641

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

tools/public_api_guard/material/menu.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ export class _MatMenuDirectivesModule {
198198
// @public
199199
export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
200200
constructor(_elementRef: ElementRef<HTMLElement>,
201-
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined,
201+
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined, _ngZone?: NgZone | undefined,
202202
_changeDetectorRef?: ChangeDetectorRef | undefined);
203-
_checkDisabled(event: Event): void;
203+
_content: ElementRef<HTMLElement> | undefined;
204204
focus(origin?: FocusOrigin, options?: FocusOptions): void;
205205
readonly _focused: Subject<MatMenuItem>;
206206
_getHostElement(): HTMLElement;
@@ -226,7 +226,7 @@ export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, Ca
226226
// (undocumented)
227227
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "role": "role"; }, {}, never, ["*"]>;
228228
// (undocumented)
229-
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null]>;
229+
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null, null]>;
230230
}
231231

232232
// @public (undocumented)

0 commit comments

Comments
 (0)