From 36221d329acabcdc1773375e7dcdbca37bdc95de Mon Sep 17 00:00:00 2001 From: Karan Mistry Date: Tue, 8 Apr 2025 15:50:19 +0530 Subject: [PATCH] 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 --- goldens/material/menu/index.api.md | 5 ++- src/dev-app/menu/BUILD.bazel | 2 + src/dev-app/menu/menu-demo.html | 70 +++++++++++++++++++++--------- src/dev-app/menu/menu-demo.ts | 28 ++++++++++-- src/material/menu/_m2-menu.scss | 3 ++ src/material/menu/_m3-menu.scss | 8 ++++ src/material/menu/menu-item.ts | 9 +++- src/material/menu/menu.scss | 17 +++++++- src/material/menu/menu.spec.ts | 43 +++++++++++++++++- 9 files changed, 157 insertions(+), 28 deletions(-) diff --git a/goldens/material/menu/index.api.md b/goldens/material/menu/index.api.md index 8662110179c9..574e2e506690 100644 --- a/goldens/material/menu/index.api.md +++ b/goldens/material/menu/index.api.md @@ -151,6 +151,7 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { constructor(...args: unknown[]); _checkDisabled(event: Event): void; disabled: boolean; + disabledInteractive: boolean; disableRipple: boolean; focus(origin?: FocusOrigin, options?: FocusOptions): void; readonly _focused: Subject; @@ -165,6 +166,8 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_disabledInteractive: unknown; + // (undocumented) static ngAcceptInputType_disableRipple: unknown; // (undocumented) ngAfterViewInit(): void; @@ -179,7 +182,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/dev-app/menu/BUILD.bazel b/src/dev-app/menu/BUILD.bazel index d49f90285623..4e4654b464bb 100644 --- a/src/dev-app/menu/BUILD.bazel +++ b/src/dev-app/menu/BUILD.bazel @@ -13,10 +13,12 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//src/material/button", + "//src/material/checkbox", "//src/material/divider", "//src/material/icon", "//src/material/menu", "//src/material/toolbar", + "//src/material/tooltip", ], ) diff --git a/src/dev-app/menu/menu-demo.html b/src/dev-app/menu/menu-demo.html index d04c3a8f5a4f..2e29b33086ea 100644 --- a/src/dev-app/menu/menu-demo.html +++ b/src/dev-app/menu/menu-demo.html @@ -10,7 +10,12 @@ @for (item of items; track item) { - } @@ -27,7 +32,12 @@ @for (item of items; track item) { - + @if (!$last) { } @@ -98,16 +108,18 @@ @for (item of items; track item) { - + {{ item.text }} }
-

- Position x: before -

+

Position x: before

@@ -124,9 +139,7 @@
-

- Position y: above -

+

Position y: above

+ }
@@ -153,14 +171,17 @@ @for (item of items; track item) { - + }
-

- Position x: before, overlapTrigger: true -

+

Position x: before, overlapTrigger: true

@@ -177,9 +201,7 @@
-

- Position y: above, overlapTrigger: true -

+

Position y: above, overlapTrigger: true

+ }
+
+ Disabled interactive +
This div is for testing scrolled menus.
diff --git a/src/dev-app/menu/menu-demo.ts b/src/dev-app/menu/menu-demo.ts index a1dc590dbaa4..216e57f4edf6 100644 --- a/src/dev-app/menu/menu-demo.ts +++ b/src/dev-app/menu/menu-demo.ts @@ -7,31 +7,53 @@ */ import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; +import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatDividerModule} from '@angular/material/divider'; import {MatIconModule} from '@angular/material/icon'; import {MatMenuModule} from '@angular/material/menu'; import {MatToolbarModule} from '@angular/material/toolbar'; +import {MatTooltip} from '@angular/material/tooltip'; @Component({ selector: 'menu-demo', templateUrl: 'menu-demo.html', styleUrl: 'menu-demo.css', - imports: [MatMenuModule, MatButtonModule, MatToolbarModule, MatIconModule, MatDividerModule], + imports: [ + FormsModule, + MatButtonModule, + MatCheckboxModule, + MatDividerModule, + MatIconModule, + MatMenuModule, + MatToolbarModule, + MatTooltip, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class MenuDemo { selected = ''; + disabledInteractive = false; + items = [ {text: 'Refresh'}, {text: 'Settings'}, - {text: 'Help', disabled: true}, + { + text: 'Help', + disabled: true, + tooltipText: 'This is a menu item tooltip!', + }, {text: 'Sign Out'}, ]; iconItems = [ {text: 'Redial', icon: 'dialpad'}, - {text: 'Check voicemail', icon: 'voicemail', disabled: true}, + { + text: 'Check voicemail', + icon: 'voicemail', + disabled: true, + }, {text: 'Disable alerts', icon: 'notifications_off'}, ]; diff --git a/src/material/menu/_m2-menu.scss b/src/material/menu/_m2-menu.scss index dd4655c8835d..61ec26272a62 100644 --- a/src/material/menu/_m2-menu.scss +++ b/src/material/menu/_m2-menu.scss @@ -31,13 +31,16 @@ $prefix: (mat, menu); $is-dark: inspection.get-theme-type($theme) == dark; $active-state-layer-color: inspection.get-theme-color($theme, foreground, base, if($is-dark, 0.08, 0.04)); + $disabled-background: inspection.get-theme-color($theme, foreground, disabled-button); $text-color: inspection.get-theme-color($theme, foreground, text); @return ( item-label-text-color: $text-color, item-icon-color: $text-color, item-hover-state-layer-color: $active-state-layer-color, + item-disabled-hover-state-layer-color: $disabled-background, item-focus-state-layer-color: $active-state-layer-color, + item-disabled-focus-state-layer-color: $disabled-background, container-color: inspection.get-theme-color($theme, background, card), divider-color: inspection.get-theme-color($theme, foreground, divider), ); diff --git a/src/material/menu/_m3-menu.scss b/src/material/menu/_m3-menu.scss index a23b6da9e49e..4d17740d6ebe 100644 --- a/src/material/menu/_m3-menu.scss +++ b/src/material/menu/_m3-menu.scss @@ -26,10 +26,18 @@ $prefix: (mat, menu); map.get($systems, md-sys-color, on-surface), $alpha: map.get($systems, md-sys-state, hover-state-layer-opacity) ), + item-disabled-hover-state-layer-color: sass-utils.safe-color-change( + map.get($systems, md-sys-color, on-surface), + $alpha: 0.38 + ), item-focus-state-layer-color: sass-utils.safe-color-change( map.get($systems, md-sys-color, on-surface), $alpha: map.get($systems, md-sys-state, focus-state-layer-opacity) ), + item-disabled-focus-state-layer-color: sass-utils.safe-color-change( + map.get($systems, md-sys-color, on-surface), + $alpha: 0.38 + ), item-spacing: m3-utils.hardcode(12px, $exclude-hardcoded), item-leading-spacing: m3-utils.hardcode(12px, $exclude-hardcoded), item-trailing-spacing: m3-utils.hardcode(12px, $exclude-hardcoded), diff --git a/src/material/menu/menu-item.ts b/src/material/menu/menu-item.ts index 6f6cfb5ec3b6..5805380edbdc 100644 --- a/src/material/menu/menu-item.ts +++ b/src/material/menu/menu-item.ts @@ -37,8 +37,9 @@ 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.aria-disabled]': 'disabled && disabledInteractive ? "true" : null', '[attr.disabled]': 'disabled || null', + '[class.mat-mdc-menu-item-disabled-interactive]': 'disabledInteractive', '(click)': '_checkDisabled($event)', '(mouseenter)': '_handleMouseEnter()', }, @@ -63,6 +64,10 @@ export class MatMenuItem implements FocusableOption, AfterViewInit, OnDestroy { /** Whether ripples are disabled on the menu item. */ @Input({transform: booleanAttribute}) disableRipple: boolean = false; + /** Whether the menu item should remain interactive when it is disabled. */ + @Input({transform: booleanAttribute}) + disabledInteractive: boolean = false; + /** Stream that emits when the menu item is hovered. */ readonly _hovered: Subject = new Subject(); @@ -117,7 +122,7 @@ 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'; } /** Returns the host DOM element. */ diff --git a/src/material/menu/menu.scss b/src/material/menu/menu.scss index 517f0626b524..c1d8a8ba86b6 100644 --- a/src/material/menu/menu.scss +++ b/src/material/menu/menu.scss @@ -165,7 +165,9 @@ mat-menu { // The class selector isn't specific enough to overide the link pseudo selectors so we need // to target them specifically, otherwise the item color might be overwritten by the user // agent resets of the app. - &, &:visited, &:link { + &, + &:visited, + &:link { color: token-utils.slot(item-label-text-color); } @@ -192,6 +194,19 @@ mat-menu { bottom: 0; right: 0; } + @include token-utils.use-tokens($token-prefix, $token-slots) { + &.mat-mdc-menu-item-disabled-interactive { + &:hover { + background-color: token-utils.slot(item-disabled-hover-state-layer-color); + } + + &.cdk-program-focused, + &.cdk-keyboard-focused, + &.mat-mdc-menu-item-highlighted { + background-color: token-utils.slot(item-disabled-focus-state-layer-color); + } + } + } } // Inherited from MDC and necessary for some internal tests. diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index 8b990c7ddf3f..5250fdf4246c 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -2563,8 +2563,27 @@ describe('MatMenu', () => { })); }); + describe('disabledInteractive', () => { + it('should be have `mat-mdc-menu-item-disabled-interactive` if disabledInteractive is set to true', fakeAsync(() => { + let fixture = createComponent(SimpleMenuWithRepeaterAndDisabledInteractive); + + fixture.detectChanges(); + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + tick(500); + + let menuPanel = document.querySelector('.mat-mdc-menu-panel')!; + let items = menuPanel.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]'); + + expect(items[0].classList).toContain('mat-mdc-menu-item-disabled-interactive'); + expect(items[1].classList).toContain('mat-mdc-menu-item-disabled-interactive'); + + flush(); + })); + }); + it('should have a focus indicator', fakeAsync(() => { - const fixture = createComponent(SimpleMenu, [], [FakeIcon]); + const fixture = createComponent(SimpleMenu, [], []); fixture.detectChanges(); fixture.componentInstance.trigger.openMenu(); fixture.detectChanges(); @@ -2950,6 +2969,28 @@ class SimpleMenuWithRepeater { ]; } +@Component({ + template: ` + + + @for (item of items; track $index) { + + } + + `, + standalone: false, +}) +class SimpleMenuWithRepeaterAndDisabledInteractive { + @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; + @ViewChild(MatMenu) menu: MatMenu; + @ViewChildren(MatMenuItem) itemInstances: QueryList; + + items = [ + {label: 'Pizza', disabled: false}, + {label: 'Pasta', disabled: true}, + ]; +} + @Component({ template: `