From 7cc3eb45e90295577f1d5c4e6a6763a3b84319e1 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 3 Nov 2020 22:02:26 +0100 Subject: [PATCH] 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. --- .../mdc-menu/menu-item.html | 2 +- src/material-experimental/mdc-menu/menu.scss | 11 ++-- .../mdc-menu/menu.spec.ts | 17 ++++++ src/material/menu/menu-item.html | 2 +- src/material/menu/menu-item.ts | 55 ++++++++++++++----- src/material/menu/menu.scss | 9 --- src/material/menu/menu.spec.ts | 17 ++++++ tools/public_api_guard/material/menu.d.ts | 6 +- 8 files changed, 84 insertions(+), 35 deletions(-) diff --git a/src/material-experimental/mdc-menu/menu-item.html b/src/material-experimental/mdc-menu/menu-item.html index 3092e914580e..e3833babd81c 100644 --- a/src/material-experimental/mdc-menu/menu-item.html +++ b/src/material-experimental/mdc-menu/menu-item.html @@ -1,4 +1,4 @@ - +
diff --git a/src/material-experimental/mdc-menu/menu.scss b/src/material-experimental/mdc-menu/menu.scss index d52d2093a41a..fff97f36e8ee 100644 --- a/src/material-experimental/mdc-menu/menu.scss +++ b/src/material-experimental/mdc-menu/menu.scss @@ -61,12 +61,6 @@ mat-menu { text-decoration: none; &[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; } @@ -98,6 +92,11 @@ mat-menu { } } +.mat-mdc-menu-item-content { + display: flex; + align-items: center; +} + // Renders out a chevron on menu items that trigger a sub-menu. .mat-mdc-menu-item-submenu-trigger { @include menu-common.item-submenu-trigger(mdc-list.$deprecated-side-padding); diff --git a/src/material-experimental/mdc-menu/menu.spec.ts b/src/material-experimental/mdc-menu/menu.spec.ts index ac003751e194..d1f456f4b9e6 100644 --- a/src/material-experimental/mdc-menu/menu.spec.ts +++ b/src/material-experimental/mdc-menu/menu.spec.ts @@ -613,6 +613,23 @@ describe('MDC-based MatMenu', () => { expect(items.every(item => item.getAttribute('role') === 'menuitem')).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 be able to set an alternate role on the menu items', () => { const fixture = createComponent(MenuWithCheckboxItems); fixture.detectChanges(); diff --git a/src/material/menu/menu-item.html b/src/material/menu/menu-item.html index a025ff57a01e..53a7b2c4d003 100644 --- a/src/material/menu/menu-item.html +++ b/src/material/menu/menu-item.html @@ -1,4 +1,4 @@ - +
diff --git a/src/material/menu/menu-item.ts b/src/material/menu/menu-item.ts index b95e9c746bd0..3463e39bef11 100644 --- a/src/material/menu/menu-item.ts +++ b/src/material/menu/menu-item.ts @@ -18,7 +18,9 @@ import { Optional, Input, HostListener, + NgZone, AfterViewInit, + ViewChild, } from '@angular/core'; import { CanDisable, @@ -61,6 +63,9 @@ export class MatMenuItem extends _MatMenuItemBase /** ARIA role for the menu item. */ @Input() role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox' = 'menuitem'; + /** Reference to the element wrapping the projected content. */ + @ViewChild('content') _content: ElementRef | undefined; + /** Stream that emits when the menu item is hovered. */ readonly _hovered: Subject = new Subject(); @@ -81,9 +86,11 @@ export class MatMenuItem extends _MatMenuItemBase */ @Inject(DOCUMENT) _document?: any, private _focusMonitor?: FocusMonitor, - @Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel) { + @Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel, + private _ngZone?: NgZone) { // @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) { @@ -109,6 +116,13 @@ export class MatMenuItem extends _MatMenuItemBase // 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() { @@ -120,6 +134,11 @@ export class MatMenuItem extends _MatMenuItemBase 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(); } @@ -134,20 +153,6 @@ export class MatMenuItem extends _MatMenuItemBase return this._elementRef.nativeElement; } - /** Prevents the default element actions if it is disabled. */ - // We have to use a `HostListener` here in order to support both Ivy and ViewEngine. - // In Ivy the `host` bindings will be merged when this class is extended, whereas in - // ViewEngine they're overwritten. - // TODO(crisbeto): we move this back into `host` once Ivy is turned on by default. - // tslint:disable-next-line:no-host-decorator-in-concrete - @HostListener('click', ['$event']) - _checkDisabled(event: Event): void { - if (this.disabled) { - event.preventDefault(); - event.stopPropagation(); - } - } - /** Emits to the hover stream. */ // We have to use a `HostListener` here in order to support both Ivy and ViewEngine. // In Ivy the `host` bindings will be merged when this class is extended, whereas in @@ -173,6 +178,26 @@ export class MatMenuItem extends _MatMenuItemBase return clone.textContent?.trim() || ''; } + /** 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(); + } + } + static ngAcceptInputType_disabled: BooleanInput; static ngAcceptInputType_disableRipple: BooleanInput; } diff --git a/src/material/menu/menu.scss b/src/material/menu/menu.scss index 5f019a8c5216..e0cd5823f0e0 100644 --- a/src/material/menu/menu.scss +++ b/src/material/menu/menu.scss @@ -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; diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index b368ccbcb984..cd1001010f75 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -612,6 +612,23 @@ describe('MatMenu', () => { expect(items.every(item => item.getAttribute('role') === 'menuitem')).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 be able to set an alternate role on the menu items', () => { const fixture = createComponent(MenuWithCheckboxItems); fixture.detectChanges(); diff --git a/tools/public_api_guard/material/menu.d.ts b/tools/public_api_guard/material/menu.d.ts index a7353c0b26c2..94ba6f115ecb 100644 --- a/tools/public_api_guard/material/menu.d.ts +++ b/tools/public_api_guard/material/menu.d.ts @@ -103,6 +103,7 @@ export interface MatMenuDefaultOptions { } export declare class MatMenuItem extends _MatMenuItemBase implements FocusableOption, CanDisable, CanDisableRipple, AfterViewInit, OnDestroy { + _content: ElementRef | undefined; readonly _focused: Subject; _highlighted: boolean; readonly _hovered: Subject; @@ -110,8 +111,7 @@ export declare class MatMenuItem extends _MatMenuItemBase implements FocusableOp _triggersSubmenu: boolean; role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox'; constructor(_elementRef: ElementRef, - _document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel | undefined); - _checkDisabled(event: Event): void; + _document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel | undefined, _ngZone?: NgZone | undefined); _getHostElement(): HTMLElement; _getTabIndex(): string; _handleMouseEnter(): void; @@ -122,7 +122,7 @@ export declare class MatMenuItem extends _MatMenuItemBase implements FocusableOp static ngAcceptInputType_disableRipple: BooleanInput; static ngAcceptInputType_disabled: BooleanInput; static ɵcmp: i0.ɵɵComponentDeclaration; - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } export declare class MatMenuModule {