Skip to content

Commit fe167d5

Browse files
committed
feat(material/menu): enhance menu item component with interactive disabled state
In menu-item component add an option `disabledInteractive` to interact with menu item in disabled state similar to `mat-button`, `mat-radio`, etc Fixes #29984
1 parent 87f0621 commit fe167d5

File tree

7 files changed

+145
-28
lines changed

7 files changed

+145
-28
lines changed

Diff for: goldens/material/menu/index.api.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
151151
constructor(...args: unknown[]);
152152
_checkDisabled(event: Event): void;
153153
disabled: boolean;
154+
disabledInteractive: boolean;
154155
disableRipple: boolean;
155156
focus(origin?: FocusOrigin, options?: FocusOptions): void;
156157
readonly _focused: Subject<MatMenuItem>;
@@ -165,6 +166,8 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
165166
// (undocumented)
166167
static ngAcceptInputType_disabled: unknown;
167168
// (undocumented)
169+
static ngAcceptInputType_disabledInteractive: unknown;
170+
// (undocumented)
168171
static ngAcceptInputType_disableRipple: unknown;
169172
// (undocumented)
170173
ngAfterViewInit(): void;
@@ -179,7 +182,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
179182
_setTriggersSubmenu(triggersSubmenu: boolean): void;
180183
_triggersSubmenu: boolean;
181184
// (undocumented)
182-
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>;
185+
static ɵcmp: i0.ɵɵComponentDeclaration<MatMenuItem, "[mat-menu-item]", ["matMenuItem"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "disabledInteractive": { "alias": "disabledInteractive"; "required": false; }; }, {}, never, ["mat-icon, [matMenuItemIcon]", "*"], true, never>;
183186
// (undocumented)
184187
static ɵfac: i0.ɵɵFactoryDeclaration<MatMenuItem, never>;
185188
}

Diff for: src/dev-app/menu/BUILD.bazel

+2
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ ng_project(
1313
deps = [
1414
"//:node_modules/@angular/core",
1515
"//src/material/button",
16+
"//src/material/checkbox",
1617
"//src/material/divider",
1718
"//src/material/icon",
1819
"//src/material/menu",
1920
"//src/material/toolbar",
21+
"//src/material/tooltip",
2022
],
2123
)
2224

Diff for: src/dev-app/menu/menu-demo.html

+50-20
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
<mat-menu #menu="matMenu">
1212
@for (item of items; track item) {
13-
<button mat-menu-item (click)="select(item.text)" [disabled]="item.disabled">
13+
<button
14+
mat-menu-item
15+
(click)="select(item.text)"
16+
[disabled]="item.disabled"
17+
[disabledInteractive]="disabledInteractive"
18+
[matTooltip]="item.tooltipText">
1419
{{ item.text }}
1520
</button>
1621
}
@@ -27,7 +32,12 @@
2732

2833
<mat-menu #divider="matMenu">
2934
@for (item of items; track item) {
30-
<button mat-menu-item [disabled]="item.disabled">{{ item.text }}</button>
35+
<button
36+
mat-menu-item
37+
[disabled]="item.disabled"
38+
[disabledInteractive]="disabledInteractive">
39+
{{ item.text }}
40+
</button>
3141
@if (!$last) {
3242
<mat-divider></mat-divider>
3343
}
@@ -98,16 +108,18 @@
98108

99109
<mat-menu #anchorMenu="matMenu">
100110
@for (item of items; track item) {
101-
<a mat-menu-item href="https://www.google.com" [disabled]="item.disabled">
111+
<a
112+
mat-menu-item
113+
href="https://www.google.com"
114+
[disabled]="item.disabled"
115+
[disabledInteractive]="disabledInteractive">
102116
{{ item.text }}
103117
</a>
104118
}
105119
</mat-menu>
106120
</div>
107121
<div class="demo-menu-section">
108-
<p>
109-
Position x: before
110-
</p>
122+
<p>Position x: before</p>
111123
<mat-toolbar class="demo-end-icon">
112124
<button matIconButton [matMenuTriggerFor]="posXMenu" aria-label="Open x-positioned menu">
113125
<mat-icon>more_vert</mat-icon>
@@ -116,17 +128,18 @@
116128

117129
<mat-menu xPosition="before" #posXMenu="matMenu">
118130
@for (item of iconItems; track item) {
119-
<button mat-menu-item [disabled]="item.disabled">
131+
<button
132+
mat-menu-item
133+
[disabled]="item.disabled"
134+
[disabledInteractive]="disabledInteractive">
120135
<mat-icon>{{ item.icon }}</mat-icon>
121136
{{ item.text }}
122137
</button>
123138
}
124139
</mat-menu>
125140
</div>
126141
<div class="demo-menu-section">
127-
<p>
128-
Position y: above
129-
</p>
142+
<p>Position y: above</p>
130143
<mat-toolbar>
131144
<button matIconButton [matMenuTriggerFor]="posYMenu" aria-label="Open y-positioned menu">
132145
<mat-icon>more_vert</mat-icon>
@@ -135,7 +148,12 @@
135148

136149
<mat-menu yPosition="above" #posYMenu="matMenu">
137150
@for (item of items; track item) {
138-
<button mat-menu-item [disabled]="item.disabled">{{ item.text }}</button>
151+
<button
152+
mat-menu-item
153+
[disabled]="item.disabled"
154+
[disabledInteractive]="disabledInteractive">
155+
{{ item.text }}
156+
</button>
139157
}
140158
</mat-menu>
141159
</div>
@@ -153,14 +171,17 @@
153171

154172
<mat-menu [overlapTrigger]="true" #menuOverlay="matMenu">
155173
@for (item of items; track item) {
156-
<button mat-menu-item [disabled]="item.disabled">{{ item.text }}</button>
174+
<button
175+
mat-menu-item
176+
[disabled]="item.disabled"
177+
[disabledInteractive]="disabledInteractive">
178+
{{ item.text }}
179+
</button>
157180
}
158181
</mat-menu>
159182
</div>
160183
<div class="demo-menu-section">
161-
<p>
162-
Position x: before, overlapTrigger: true
163-
</p>
184+
<p>Position x: before, overlapTrigger: true</p>
164185
<mat-toolbar class="demo-end-icon">
165186
<button matIconButton [mat-menu-trigger-for]="posXMenuOverlay">
166187
<mat-icon>more_vert</mat-icon>
@@ -169,17 +190,18 @@
169190

170191
<mat-menu xPosition="before" [overlapTrigger]="true" #posXMenuOverlay="matMenu">
171192
@for (item of iconItems; track item) {
172-
<button mat-menu-item [disabled]="item.disabled">
193+
<button
194+
mat-menu-item
195+
[disabled]="item.disabled"
196+
[disabledInteractive]="disabledInteractive">
173197
<mat-icon>{{ item.icon }}</mat-icon>
174198
{{ item.text }}
175199
</button>
176200
}
177201
</mat-menu>
178202
</div>
179203
<div class="demo-menu-section">
180-
<p>
181-
Position y: above, overlapTrigger: true
182-
</p>
204+
<p>Position y: above, overlapTrigger: true</p>
183205
<mat-toolbar>
184206
<button matIconButton [mat-menu-trigger-for]="posYMenuOverlay">
185207
<mat-icon>more_vert</mat-icon>
@@ -188,10 +210,18 @@
188210

189211
<mat-menu yPosition="above" [overlapTrigger]="true" #posYMenuOverlay="matMenu">
190212
@for (item of items; track item) {
191-
<button mat-menu-item [disabled]="item.disabled">{{ item.text }}</button>
213+
<button
214+
mat-menu-item
215+
[disabled]="item.disabled"
216+
[disabledInteractive]="disabledInteractive">
217+
{{ item.text }}
218+
</button>
192219
}
193220
</mat-menu>
194221
</div>
195222
</div>
223+
<div>
224+
<mat-checkbox [(ngModel)]="disabledInteractive">Disabled interactive</mat-checkbox>
225+
</div>
196226

197227
<div style="height: 500px">This div is for testing scrolled menus.</div>

Diff for: src/dev-app/menu/menu-demo.ts

+25-3
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,53 @@
77
*/
88

99
import {ChangeDetectionStrategy, Component} from '@angular/core';
10+
import {FormsModule} from '@angular/forms';
1011
import {MatButtonModule} from '@angular/material/button';
12+
import {MatCheckboxModule} from '@angular/material/checkbox';
1113
import {MatDividerModule} from '@angular/material/divider';
1214
import {MatIconModule} from '@angular/material/icon';
1315
import {MatMenuModule} from '@angular/material/menu';
1416
import {MatToolbarModule} from '@angular/material/toolbar';
17+
import {MatTooltip} from '@angular/material/tooltip';
1518

1619
@Component({
1720
selector: 'menu-demo',
1821
templateUrl: 'menu-demo.html',
1922
styleUrl: 'menu-demo.css',
20-
imports: [MatMenuModule, MatButtonModule, MatToolbarModule, MatIconModule, MatDividerModule],
23+
imports: [
24+
FormsModule,
25+
MatButtonModule,
26+
MatCheckboxModule,
27+
MatDividerModule,
28+
MatIconModule,
29+
MatMenuModule,
30+
MatToolbarModule,
31+
MatTooltip,
32+
],
2133
changeDetection: ChangeDetectionStrategy.OnPush,
2234
})
2335
export class MenuDemo {
2436
selected = '';
37+
disabledInteractive = true;
38+
2539
items = [
2640
{text: 'Refresh'},
2741
{text: 'Settings'},
28-
{text: 'Help', disabled: true},
42+
{
43+
text: 'Help',
44+
disabled: true,
45+
tooltipText: 'This is a menu item tooltip!',
46+
},
2947
{text: 'Sign Out'},
3048
];
3149

3250
iconItems = [
3351
{text: 'Redial', icon: 'dialpad'},
34-
{text: 'Check voicemail', icon: 'voicemail', disabled: true},
52+
{
53+
text: 'Check voicemail',
54+
icon: 'voicemail',
55+
disabled: true,
56+
},
3557
{text: 'Disable alerts', icon: 'notifications_off'},
3658
];
3759

Diff for: src/material/menu/menu-item.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ 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',
40+
'[attr.aria-disabled]': 'disabled && disabledInteractive ? "true" : null',
4141
'[attr.disabled]': 'disabled || null',
42+
'[class.mat-mdc-menu-item-disabled-interactive]': 'disabledInteractive',
4243
'(click)': '_checkDisabled($event)',
4344
'(mouseenter)': '_handleMouseEnter()',
4445
},
@@ -63,6 +64,10 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
6364
/** Whether ripples are disabled on the menu item. */
6465
@Input({transform: booleanAttribute}) disableRipple: boolean = false;
6566

67+
/** Whether the menu item should remain interactive when it is disabled. */
68+
@Input({transform: booleanAttribute})
69+
disabledInteractive: boolean = false;
70+
6671
/** Stream that emits when the menu item is hovered. */
6772
readonly _hovered: Subject<MatMenuItem> = new Subject<MatMenuItem>();
6873

@@ -117,7 +122,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
117122

118123
/** Used to set the `tabindex`. */
119124
_getTabIndex(): string {
120-
return this.disabled ? '-1' : '0';
125+
return this.disabled && !this.disabledInteractive ? '-1' : '0';
121126
}
122127

123128
/** Returns the host DOM element. */
@@ -130,6 +135,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy {
130135
if (this.disabled) {
131136
event.preventDefault();
132137
event.stopPropagation();
138+
return;
133139
}
134140
}
135141

Diff for: src/material/menu/menu.scss

+14-1
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ mat-menu {
165165
// The class selector isn't specific enough to overide the link pseudo selectors so we need
166166
// to target them specifically, otherwise the item color might be overwritten by the user
167167
// agent resets of the app.
168-
&, &:visited, &:link {
168+
&,
169+
&:visited,
170+
&:link {
169171
color: token-utils.slot(item-label-text-color);
170172
}
171173

@@ -178,6 +180,7 @@ mat-menu {
178180
&[disabled] {
179181
cursor: default;
180182
opacity: 0.38;
183+
pointer-events: none;
181184

182185
// The browser prevents clicks on disabled buttons from propagating which prevents the menu
183186
// from closing, but clicks on child nodes still propagate which is inconsistent (see #16694).
@@ -192,6 +195,16 @@ mat-menu {
192195
bottom: 0;
193196
right: 0;
194197
}
198+
199+
@include token-utils.use-tokens($token-prefix, $token-slots) {
200+
&.mat-mdc-menu-item-disabled-interactive {
201+
pointer-events: auto;
202+
203+
&:hover {
204+
background-color: token-utils.slot(item-hover-state-layer-color);
205+
}
206+
}
207+
}
195208
}
196209

197210
// Inherited from MDC and necessary for some internal tests.

Diff for: src/material/menu/menu.spec.ts

+42-1
Original file line numberDiff line numberDiff line change
@@ -2563,8 +2563,27 @@ describe('MatMenu', () => {
25632563
}));
25642564
});
25652565

2566+
describe('disabledInteractive', () => {
2567+
it('should be have `mat-mdc-menu-item-disabled-interactive` if disabledInteractive is set to true', fakeAsync(() => {
2568+
let fixture = createComponent(SimpleMenuWithRepeaterAndDisabledInteractive);
2569+
2570+
fixture.detectChanges();
2571+
fixture.componentInstance.trigger.openMenu();
2572+
fixture.detectChanges();
2573+
tick(500);
2574+
2575+
let menuPanel = document.querySelector('.mat-mdc-menu-panel')!;
2576+
let items = menuPanel.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]');
2577+
2578+
expect(items[0].classList).toContain('mat-mdc-menu-item-disabled-interactive');
2579+
expect(items[1].classList).toContain('mat-mdc-menu-item-disabled-interactive');
2580+
2581+
flush();
2582+
}));
2583+
});
2584+
25662585
it('should have a focus indicator', fakeAsync(() => {
2567-
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
2586+
const fixture = createComponent(SimpleMenu, [], []);
25682587
fixture.detectChanges();
25692588
fixture.componentInstance.trigger.openMenu();
25702589
fixture.detectChanges();
@@ -2950,6 +2969,28 @@ class SimpleMenuWithRepeater {
29502969
];
29512970
}
29522971

2972+
@Component({
2973+
template: `
2974+
<button [matMenuTriggerFor]="menu">Toggle menu</button>
2975+
<mat-menu #menu="matMenu">
2976+
@for (item of items; track $index) {
2977+
<button [disabled]="item.disabled" mat-menu-item [disabledInteractive]="true">{{item.label}}</button>
2978+
}
2979+
</mat-menu>
2980+
`,
2981+
standalone: false,
2982+
})
2983+
class SimpleMenuWithRepeaterAndDisabledInteractive {
2984+
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
2985+
@ViewChild(MatMenu) menu: MatMenu;
2986+
@ViewChildren(MatMenuItem) itemInstances: QueryList<MatMenuItem>;
2987+
2988+
items = [
2989+
{label: 'Pizza', disabled: false},
2990+
{label: 'Pasta', disabled: true},
2991+
];
2992+
}
2993+
29532994
@Component({
29542995
template: `
29552996
<button [matMenuTriggerFor]="menu">Toggle menu</button>

0 commit comments

Comments
 (0)