Skip to content

Commit

Permalink
fix(material/menu): clicks on disabled item closing the menu
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Jan 3, 2022
1 parent 03485cd commit b361c97
Show file tree
Hide file tree
Showing 8 changed files with 83 additions and 28 deletions.
2 changes: 1 addition & 1 deletion src/material-experimental/mdc-menu/menu-item.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<ng-content select="mat-icon"></ng-content>
<span class="mdc-list-item__primary-text"><ng-content></ng-content></span>
<span class="mdc-list-item__primary-text mat-mdc-menu-item-content" #content><ng-content></ng-content></span>
<div class="mat-mdc-menu-ripple" matRipple
[matRippleDisabled]="disableRipple || disabled"
[matRippleTrigger]="_getHostElement()">
Expand Down
11 changes: 5 additions & 6 deletions src/material-experimental/mdc-menu/menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,6 @@ mat-menu {
min-height: map.get($height-config, default);

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

// This is the same as `mdc-list-mixins.list-disabled-opacity` which
Expand Down Expand Up @@ -107,6 +101,11 @@ mat-menu {
}
}

.mat-mdc-menu-item-content {
display: flex;
align-items: center;
}

.mat-mdc-menu-item-submenu-trigger {
@include menu-common.item-submenu-trigger(mdc-list-variables.$side-padding);
}
Expand Down
17 changes: 17 additions & 0 deletions src/material-experimental/mdc-menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,23 @@ describe('MDC-based MatMenu', () => {
expect(items.every(item => item.getAttribute('role') === 'menuitemcheckbox')).toBe(true);
}));

it('should prevent the default action when clicking on a disabled item', () => {
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();

const item = overlayContainerElement.querySelector('.mat-mdc-menu-item[disabled]')!;
const itemEvent = dispatchFakeEvent(item, 'click');
fixture.detectChanges();
expect(itemEvent.defaultPrevented).toBe(true);

const contentWrapper = item.querySelector('span')!;
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
fixture.detectChanges();
expect(wrapperEvent.defaultPrevented).toBe(true);
});

it('should not change focus origin if origin not specified for menu items', fakeAsync(() => {
const fixture = createComponent(MenuWithCheckboxItems);
fixture.detectChanges();
Expand Down
2 changes: 1 addition & 1 deletion src/material/menu/menu-item.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ng-content></ng-content>
<span #content><ng-content></ng-content></span>
<div class="mat-menu-ripple" matRipple
[matRippleDisabled]="disableRipple || disabled"
[matRippleTrigger]="_getHostElement()">
Expand Down
47 changes: 39 additions & 8 deletions src/material/menu/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
Inject,
Optional,
Input,
NgZone,
AfterViewInit,
ChangeDetectorRef,
ViewChild,
} from '@angular/core';
import {
CanDisable,
Expand Down Expand Up @@ -63,6 +65,9 @@ export class MatMenuItem
/** ARIA role for the menu item. */
@Input() role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox' = 'menuitem';

/** Reference to the element wrapping the projected content. */
@ViewChild('content') _content: ElementRef<HTMLElement> | undefined;

/** Stream that emits when the menu item is hovered. */
readonly _hovered: Subject<MatMenuItem> = new Subject<MatMenuItem>();

Expand All @@ -84,13 +89,15 @@ export class MatMenuItem
@Inject(DOCUMENT) _document?: any,
private _focusMonitor?: FocusMonitor,
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
private _ngZone?: NgZone,
/**
* @deprecated `_changeDetectorRef` to become a required parameter.
* @breaking-change 14.0.0
*/
private _changeDetectorRef?: ChangeDetectorRef,
) {
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
// @breaking-change 11.0.0 make `_ngZone` a required parameter.
super();

if (_parentMenu && _parentMenu.addItem) {
Expand All @@ -116,6 +123,13 @@ export class MatMenuItem
// mouse or touch interaction.
this._focusMonitor.monitor(this._elementRef, false);
}

// @breaking-change 11.0.0 Remove null check for `_ngZone`.
if (this._ngZone) {
this._ngZone.runOutsideAngular(() => this._bindDisabledClickEvents());
} else {
this._bindDisabledClickEvents();
}
}

ngOnDestroy() {
Expand All @@ -127,6 +141,11 @@ export class MatMenuItem
this._parentMenu.removeItem(this);
}

this._elementRef.nativeElement.removeEventListener('click', this._preventDisabledClicks);
if (this._content) {
this._content.nativeElement.removeEventListener('click', this._preventDisabledClicks);
}

this._hovered.complete();
this._focused.complete();
}
Expand All @@ -141,14 +160,6 @@ export class MatMenuItem
return this._elementRef.nativeElement;
}

/** Prevents the default element actions if it is disabled. */
_checkDisabled(event: Event): void {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}

/** Emits to the hover stream. */
_handleMouseEnter() {
this._hovered.next(this);
Expand All @@ -175,4 +186,24 @@ export class MatMenuItem
this._highlighted = isHighlighted;
this._changeDetectorRef?.markForCheck();
}

/** Binds the click events that prevent the default actions while disabled. */
private _bindDisabledClickEvents() {
// We need to bind this event both on the root node and the content wrapper, because browsers
// won't dispatch events on disabled `button` nodes, but they'll still be dispatched if the
// user interacts with a non-disabled child of the button. This means that can get regions
// inside a disabled menu item where clicks land and others where they don't.
this._elementRef.nativeElement.addEventListener('click', this._preventDisabledClicks);
if (this._content) {
this._content.nativeElement.addEventListener('click', this._preventDisabledClicks);
}
}

/** Prevents the default click action if the menu item is disabled. */
private _preventDisabledClicks = (event: Event) => {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
};
}
9 changes: 0 additions & 9 deletions src/material/menu/menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,6 @@ mat-menu {
@include menu-common.item-base();
position: relative;

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

@include a11y.high-contrast(active, off) {
$outline-width: 1px;

Expand Down
17 changes: 17 additions & 0 deletions src/material/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,23 @@ describe('MatMenu', () => {
expect(items.every(item => item.getAttribute('role') === 'menuitemcheckbox')).toBe(true);
}));

it('should prevent the default action when clicking on a disabled item', () => {
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();

const item = overlayContainerElement.querySelector('.mat-menu-item[disabled]')!;
const itemEvent = dispatchFakeEvent(item, 'click');
fixture.detectChanges();
expect(itemEvent.defaultPrevented).toBe(true);

const contentWrapper = item.querySelector('span')!;
const wrapperEvent = dispatchFakeEvent(contentWrapper, 'click');
fixture.detectChanges();
expect(wrapperEvent.defaultPrevented).toBe(true);
});

it('should not change focus origin if origin not specified for menu items', fakeAsync(() => {
const fixture = createComponent(MenuWithCheckboxItems);
fixture.detectChanges();
Expand Down
6 changes: 3 additions & 3 deletions tools/public_api_guard/material/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,9 @@ export interface MatMenuDefaultOptions {
// @public
export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy {
constructor(_elementRef: ElementRef<HTMLElement>,
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined,
_document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined, _ngZone?: NgZone | undefined,
_changeDetectorRef?: ChangeDetectorRef | undefined);
_checkDisabled(event: Event): void;
_content: ElementRef<HTMLElement> | undefined;
focus(origin?: FocusOrigin, options?: FocusOptions): void;
readonly _focused: Subject<MatMenuItem>;
_getHostElement(): HTMLElement;
Expand All @@ -216,7 +216,7 @@ export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, Ca
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "role": "role"; }, {}, never, ["*"]>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null]>;
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, [null, null, null, { optional: true; }, null, null]>;
}

// @public (undocumented)
Expand Down

0 comments on commit b361c97

Please sign in to comment.