From 4ef5c226eb39386b987b37133bc3a1d9c92d3446 Mon Sep 17 00:00:00 2001 From: Jeremy Mowery Date: Mon, 17 Aug 2026 14:05:15 -0700 Subject: [PATCH] feat(material/menu): add disabledInteractive input to MatMenuItem This adds the `disabledInteractive` input to `MatMenuItem`, bringing its behavior in line with `MatButton`. When `disabledInteractive` is true, the menu item takes on the visual styling of a disabled item and prevents default action on click, but allows for focus, hover states, and pointer events. This is useful for conveying why an item is disabled via a tooltip. Fixes #33692 --- goldens/material/menu/index.api.md | 9 +++++- src/material/menu/menu-item.ts | 31 ++++++++++++++++-- src/material/menu/menu.scss | 3 +- src/material/menu/menu.spec.ts | 38 +++++++++++++++++++++++ src/material/menu/testing/menu-harness.ts | 7 +++-- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/goldens/material/menu/index.api.md b/goldens/material/menu/index.api.md index 0a6dd25c09c3..b39eaf111046 100644 --- a/goldens/material/menu/index.api.md +++ b/goldens/material/menu/index.api.md @@ -172,9 +172,12 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { constructor(); _checkDisabled(event: Event): void; disabled: boolean; + disabledInteractive: boolean; disableRipple: boolean; focus(origin?: FocusOrigin, options?: FocusOptions): void; readonly _focused: Subject; + _getAriaDisabled(): boolean | null; + _getDisabled(): boolean | null; _getHostElement(): HTMLElement; getLabel(): string; _getTabIndex(): string; @@ -184,8 +187,12 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { _highlighted: boolean; readonly _hovered: Subject; // (undocumented) + _isAnchor: boolean; + // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_disabledInteractive: unknown; + // (undocumented) static ngAcceptInputType_disableRipple: unknown; // (undocumented) ngAfterViewInit(): void; @@ -200,7 +207,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { _setTriggersSubmenu(triggersSubmenu: boolean): void; _triggersSubmenu: boolean; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/src/material/menu/menu-item.ts b/src/material/menu/menu-item.ts index e16ea30a4114..c4faec5da329 100644 --- a/src/material/menu/menu-item.ts +++ b/src/material/menu/menu-item.ts @@ -37,8 +37,10 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; '[class.mat-mdc-menu-item-highlighted]': '_highlighted', '[class.mat-mdc-menu-item-submenu-trigger]': '_triggersSubmenu', '[attr.tabindex]': '_getTabIndex()', - '[attr.aria-disabled]': 'disabled', - '[attr.disabled]': 'disabled || null', + '[attr.aria-disabled]': '_getAriaDisabled()', + '[attr.disabled]': '_getDisabled()', + '[class.mat-mdc-menu-item-disabled]': 'disabled', + '[class.mat-mdc-menu-item-disabled-interactive]': 'disabledInteractive', '(click)': '_checkDisabled($event)', '(mouseenter)': '_handleMouseEnter()', }, @@ -47,6 +49,7 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; imports: [MatRipple], }) export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { + _isAnchor: boolean; private _elementRef = inject>(ElementRef); private _document = inject(DOCUMENT); private _focusMonitor = inject(FocusMonitor); @@ -59,6 +62,13 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { /** Whether the menu item is disabled. */ @Input({transform: booleanAttribute}) disabled: boolean = false; + /** + * Natively disabled menu items prevent focus and any pointer events from reaching the item. + * In some scenarios this might not be desirable, because it can prevent users from finding out + * why the item is disabled (e.g. via tooltip). + */ + @Input({transform: booleanAttribute}) disabledInteractive: boolean = false; + /** Whether ripples are disabled on the menu item. */ @Input({transform: booleanAttribute}) disableRipple: boolean = false; @@ -77,6 +87,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { constructor() { inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader); this._parentMenu?.addItem?.(this); + this._isAnchor = this._elementRef.nativeElement.tagName === 'A'; } /** Focuses the menu item. */ @@ -114,7 +125,21 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { /** Used to set the `tabindex`. */ _getTabIndex(): string { - return this.disabled ? '-1' : '0'; + return this.disabled && !this.disabledInteractive ? '-1' : '0'; + } + + /** Gets the `aria-disabled` value for the menu item. */ + _getAriaDisabled(): boolean | null { + if (this._isAnchor) { + return this.disabled || null; + } + + return this.disabled && this.disabledInteractive ? true : null; + } + + /** Gets the `disabled` attribute value for the menu item. */ + _getDisabled(): boolean | null { + return this.disabledInteractive || !this.disabled ? null : true; } /** Returns the host DOM element. */ diff --git a/src/material/menu/menu.scss b/src/material/menu/menu.scss index 4f8d64d46b90..08d1c63b4210 100644 --- a/src/material/menu/menu.scss +++ b/src/material/menu/menu.scss @@ -161,7 +161,8 @@ mat-menu { color: token-utils.slot(menu-item-icon-color, $fallbacks); } - &[disabled] { + &[disabled], + &.mat-mdc-menu-item-disabled { cursor: default; opacity: 0.38; diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index 969a34725eb3..cfa158b3a2fd 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -1634,6 +1634,28 @@ describe('MatMenu', () => { expect(ripple.disabled).toBe(true); }); + + it('should set disabled interactive classes and attributes', () => { + const fixture = TestBed.createComponent(DisabledInteractiveMenuItem); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css('.mat-mdc-menu-item')); + const disabledNativeItem = items[0].nativeElement; + const disabledInteractiveItem = items[1].nativeElement; + + expect(disabledNativeItem.hasAttribute('disabled')).toBe(true); + expect(disabledNativeItem.getAttribute('aria-disabled')).toBeNull(); + expect(disabledNativeItem.classList).toContain('mat-mdc-menu-item-disabled'); + expect(disabledNativeItem.classList).not.toContain('mat-mdc-menu-item-disabled-interactive'); + + expect(disabledInteractiveItem.hasAttribute('disabled')).toBe(false); + expect(disabledInteractiveItem.getAttribute('aria-disabled')).toBe('true'); + expect(disabledInteractiveItem.classList).toContain('mat-mdc-menu-item-disabled'); + expect(disabledInteractiveItem.classList).toContain('mat-mdc-menu-item-disabled-interactive'); + }); }); describe('close event', () => { @@ -2554,6 +2576,22 @@ class DisabledMenu { @ViewChild('triggerEl', {read: ElementRef}) triggerEl!: ElementRef; } +@Component({ + template: ` + + + + + + `, + imports: [MatMenuTrigger, MatMenu, MatMenuItem], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class DisabledInteractiveMenuItem { + @ViewChild(MatMenuTrigger) trigger!: MatMenuTrigger; + @ViewChild('triggerEl', {read: ElementRef}) triggerEl!: ElementRef; +} + @Component({ template: ` diff --git a/src/material/menu/testing/menu-harness.ts b/src/material/menu/testing/menu-harness.ts index 55951213b65f..e4e6bb8a4c50 100644 --- a/src/material/menu/testing/menu-harness.ts +++ b/src/material/menu/testing/menu-harness.ts @@ -170,8 +170,11 @@ export class MatMenuItemHarness extends ContentContainerComponentHarness /** Whether the menu is disabled. */ async isDisabled(): Promise { - const disabled = (await this.host()).getAttribute('disabled'); - return coerceBooleanProperty(await disabled); + const host = await this.host(); + return ( + coerceBooleanProperty(await host.getAttribute('disabled')) || + (await host.hasClass('mat-mdc-menu-item-disabled')) + ); } /** Gets the text of the menu item. */