Skip to content

Commit cacab55

Browse files
authored
feat(material/menu): add disabledInteractive input to MatMenuItem (#33693)
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
1 parent ec2dbff commit cacab55

5 files changed

Lines changed: 77 additions & 7 deletions

File tree

‎goldens/material/menu/index.api.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,12 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
172172
constructor();
173173
_checkDisabled(event: Event): void;
174174
disabled: boolean;
175+
disabledInteractive: boolean;
175176
disableRipple: boolean;
176177
focus(origin?: FocusOrigin, options?: FocusOptions): void;
177178
readonly _focused: Subject<MatMenuItem>;
179+
_getAriaDisabled(): boolean | null;
180+
_getDisabled(): boolean | null;
178181
_getHostElement(): HTMLElement;
179182
getLabel(): string;
180183
_getTabIndex(): string;
@@ -184,8 +187,12 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
184187
_highlighted: boolean;
185188
readonly _hovered: Subject<MatMenuItem>;
186189
// (undocumented)
190+
protected _isAnchor: boolean;
191+
// (undocumented)
187192
static ngAcceptInputType_disabled: unknown;
188193
// (undocumented)
194+
static ngAcceptInputType_disabledInteractive: unknown;
195+
// (undocumented)
189196
static ngAcceptInputType_disableRipple: unknown;
190197
// (undocumented)
191198
ngAfterViewInit(): void;
@@ -200,7 +207,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
200207
_setTriggersSubmenu(triggersSubmenu: boolean): void;
201208
_triggersSubmenu: boolean;
202209
// (undocumented)
203-
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; }, {}, never, ["mat-icon, [matMenuItemIcon]", "*"], true, never>;
210+
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disabledInteractive": { "alias": "disabledInteractive"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; }, {}, never, ["mat-icon, [matMenuItemIcon]", "*"], true, never>;
204211
// (undocumented)
205212
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, never>;
206213
}

‎src/material/menu/menu-item.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
3737
'[class.mat-mdc-menu-item-highlighted]': '_highlighted',
3838
'[class.mat-mdc-menu-item-submenu-trigger]': '_triggersSubmenu',
3939
'[attr.tabindex]': '_getTabIndex()',
40-
'[attr.aria-disabled]': 'disabled',
41-
'[attr.disabled]': 'disabled || null',
40+
'[attr.aria-disabled]': '_getAriaDisabled()',
41+
'[attr.disabled]': '_getDisabled()',
42+
'[class.mat-mdc-menu-item-disabled]': 'disabled',
43+
'[class.mat-mdc-menu-item-disabled-interactive]': 'disabledInteractive',
4244
'(click)': '_checkDisabled($event)',
4345
'(mouseenter)': '_handleMouseEnter()',
4446
},
@@ -47,6 +49,7 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
4749
imports: [MatRipple],
4850
})
4951
export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
52+
protected _isAnchor: boolean;
5053
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
5154
private _document = inject(DOCUMENT);
5255
private _focusMonitor = inject(FocusMonitor);
@@ -59,6 +62,13 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
5962
/** Whether the menu item is disabled. */
6063
@Input({transform: booleanAttribute}) disabled: boolean = false;
6164

65+
/**
66+
* Natively disabled menu items prevent focus and any pointer events from reaching the item.
67+
* In some scenarios this might not be desirable, because it can prevent users from finding out
68+
* why the item is disabled (e.g. via tooltip).
69+
*/
70+
@Input({transform: booleanAttribute}) disabledInteractive: boolean = false;
71+
6272
/** Whether ripples are disabled on the menu item. */
6373
@Input({transform: booleanAttribute}) disableRipple: boolean = false;
6474

@@ -77,6 +87,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
7787
constructor() {
7888
inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader);
7989
this._parentMenu?.addItem?.(this);
90+
this._isAnchor = this._elementRef.nativeElement.tagName === 'A';
8091
}
8192

8293
/** Focuses the menu item. */
@@ -114,7 +125,21 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
114125

115126
/** Used to set the `tabindex`. */
116127
_getTabIndex(): string {
117-
return this.disabled ? '-1' : '0';
128+
return this.disabled && !this.disabledInteractive ? '-1' : '0';
129+
}
130+
131+
/** Gets the `aria-disabled` value for the menu item. */
132+
_getAriaDisabled(): boolean | null {
133+
if (this._isAnchor) {
134+
return this.disabled || null;
135+
}
136+
137+
return this.disabled && this.disabledInteractive ? true : null;
138+
}
139+
140+
/** Gets the `disabled` attribute value for the menu item. */
141+
_getDisabled(): boolean | null {
142+
return this.disabledInteractive || !this.disabled ? null : true;
118143
}
119144

120145
/** Returns the host DOM element. */

‎src/material/menu/menu.scss‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ mat-menu {
161161
color: token-utils.slot(menu-item-icon-color, $fallbacks);
162162
}
163163

164-
&[disabled] {
164+
&[disabled],
165+
&.mat-mdc-menu-item-disabled {
165166
cursor: default;
166167
opacity: 0.38;
167168

‎src/material/menu/menu.spec.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,28 @@ describe('MatMenu', () => {
16341634

16351635
expect(ripple.disabled).toBe(true);
16361636
});
1637+
1638+
it('should set disabled interactive classes and attributes', () => {
1639+
const fixture = TestBed.createComponent(DisabledInteractiveMenuItem);
1640+
fixture.detectChanges();
1641+
1642+
fixture.componentInstance.trigger.openMenu();
1643+
fixture.detectChanges();
1644+
1645+
const items = fixture.debugElement.queryAll(By.css('.mat-mdc-menu-item'));
1646+
const disabledNativeItem = items[0].nativeElement;
1647+
const disabledInteractiveItem = items[1].nativeElement;
1648+
1649+
expect(disabledNativeItem.hasAttribute('disabled')).toBe(true);
1650+
expect(disabledNativeItem.getAttribute('aria-disabled')).toBeNull();
1651+
expect(disabledNativeItem.classList).toContain('mat-mdc-menu-item-disabled');
1652+
expect(disabledNativeItem.classList).not.toContain('mat-mdc-menu-item-disabled-interactive');
1653+
1654+
expect(disabledInteractiveItem.hasAttribute('disabled')).toBe(false);
1655+
expect(disabledInteractiveItem.getAttribute('aria-disabled')).toBe('true');
1656+
expect(disabledInteractiveItem.classList).toContain('mat-mdc-menu-item-disabled');
1657+
expect(disabledInteractiveItem.classList).toContain('mat-mdc-menu-item-disabled-interactive');
1658+
});
16371659
});
16381660

16391661
describe('close event', () => {
@@ -2554,6 +2576,22 @@ class DisabledMenu {
25542576
@ViewChild('triggerEl', {read: ElementRef}) triggerEl!: ElementRef<HTMLElement>;
25552577
}
25562578

2579+
@Component({
2580+
template: `
2581+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
2582+
<mat-menu #menu="matMenu">
2583+
<button mat-menu-item disabled> Native disabled </button>
2584+
<button mat-menu-item disabled [disabledInteractive]="true"> Disabled Interactive </button>
2585+
</mat-menu>
2586+
`,
2587+
imports: [MatMenuTrigger, MatMenu, MatMenuItem],
2588+
changeDetection: ChangeDetectionStrategy.Eager,
2589+
})
2590+
class DisabledInteractiveMenuItem {
2591+
@ViewChild(MatMenuTrigger) trigger!: MatMenuTrigger;
2592+
@ViewChild('triggerEl', {read: ElementRef}) triggerEl!: ElementRef<HTMLElement>;
2593+
}
2594+
25572595
@Component({
25582596
template: `
25592597
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>

‎src/material/menu/testing/menu-harness.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,7 @@ export class MatMenuItemHarness extends ContentContainerComponentHarness<string>
170170

171171
/** Whether the menu is disabled. */
172172
async isDisabled(): Promise<boolean> {
173-
const disabled = (await this.host()).getAttribute('disabled');
174-
return coerceBooleanProperty(await disabled);
173+
return (await this.host()).hasClass('mat-mdc-menu-item-disabled');
175174
}
176175

177176
/** Gets the text of the menu item. */

0 commit comments

Comments
 (0)