diff --git a/libs/components/src/menu/menu-item.component.html b/libs/components/src/menu/menu-item.component.html
index f6a05c3cc97..6e259908aa1 100644
--- a/libs/components/src/menu/menu-item.component.html
+++ b/libs/components/src/menu/menu-item.component.html
@@ -1,8 +1,12 @@
-
-
-
+ @if (loading()) {
+
+ } @else {
+
+
+
+ }
diff --git a/libs/components/src/menu/menu-item.component.ts b/libs/components/src/menu/menu-item.component.ts
index 149fc3ca297..9911f578d0c 100644
--- a/libs/components/src/menu/menu-item.component.ts
+++ b/libs/components/src/menu/menu-item.component.ts
@@ -1,55 +1,42 @@
-import { FocusableOption } from "@angular/cdk/a11y";
-import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass } from "@angular/common";
-import { Component, ElementRef, HostBinding, Input } from "@angular/core";
+import { Component, computed, ElementRef, inject, model } from "@angular/core";
+
+import { AriaDisableDirective } from "../a11y";
+import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
+import { SpinnerComponent } from "../spinner";
+import { ariaDisableElement } from "../utils";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "[bitMenuItem]",
templateUrl: "menu-item.component.html",
- imports: [NgClass],
+ imports: [NgClass, SpinnerComponent],
+ providers: [{ provide: ButtonLikeAbstraction, useExisting: MenuItemComponent }],
+ hostDirectives: [AriaDisableDirective],
+ host: {
+ class:
+ "tw-block tw-w-full tw-py-1.5 tw-px-3 !tw-text-main !tw-no-underline tw-cursor-pointer tw-border-none tw-bg-background tw-text-left hover:tw-bg-hover-default focus-visible:tw-z-50 focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-rounded-lg focus-visible:tw-ring-inset focus-visible:tw-ring-primary-600 active:!tw-ring-0 active:!tw-ring-offset-0 aria-disabled:!tw-text-muted aria-disabled:hover:tw-bg-background aria-disabled:tw-cursor-not-allowed",
+ role: "menuitem",
+ tabIndex: "-1",
+ "[attr.aria-label]":
+ "loading() ? `In progress: ${this.elementRef.nativeElement.textContent}` : null",
+ },
})
-export class MenuItemComponent implements FocusableOption {
- @HostBinding("class") classList = [
- "tw-block",
- "tw-w-full",
- "tw-py-1.5",
- "tw-px-3",
- "!tw-text-main",
- "!tw-no-underline",
- "tw-cursor-pointer",
- "tw-border-none",
- "tw-bg-background",
- "tw-text-left",
- "hover:tw-bg-hover-default",
- "focus-visible:tw-z-50",
- "focus-visible:tw-outline-none",
- "focus-visible:tw-ring-2",
- "focus-visible:tw-rounded-lg",
- "focus-visible:tw-ring-inset",
- "focus-visible:tw-ring-primary-600",
- "active:!tw-ring-0",
- "active:!tw-ring-offset-0",
- "disabled:!tw-text-muted",
- "disabled:hover:tw-bg-background",
- "disabled:tw-cursor-not-allowed",
- ];
- @HostBinding("attr.role") role = "menuitem";
- @HostBinding("tabIndex") tabIndex = "-1";
- @HostBinding("attr.disabled") get disabledAttr() {
- return this.disabled || null; // native disabled attr must be null when false
+export class MenuItemComponent implements ButtonLikeAbstraction {
+ readonly disabled = model(false);
+ readonly loading = model(false);
+ readonly elementRef = inject(ElementRef);
+
+ protected readonly disabledAttr = computed(() => {
+ const disabled = this.disabled() != null && this.disabled() !== false;
+ return disabled || this.loading();
+ });
+
+ constructor() {
+ ariaDisableElement(this.elementRef.nativeElement, this.disabledAttr);
}
- // TODO: Skipped for signal migration because:
- // This input overrides a field from a superclass, while the superclass field
- // is not migrated.
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-signals
- @Input({ transform: coerceBooleanProperty }) disabled?: boolean = false;
-
- constructor(public elementRef: ElementRef) {}
-
focus() {
this.elementRef.nativeElement.focus();
}
diff --git a/libs/components/src/menu/menu.component.ts b/libs/components/src/menu/menu.component.ts
index fc7a4673fea..ee3ce53f6e1 100644
--- a/libs/components/src/menu/menu.component.ts
+++ b/libs/components/src/menu/menu.component.ts
@@ -1,4 +1,4 @@
-import { FocusKeyManager, CdkTrapFocus } from "@angular/cdk/a11y";
+import { FocusKeyManager, CdkTrapFocus, FocusableOption } from "@angular/cdk/a11y";
import {
Component,
Output,
@@ -34,9 +34,11 @@ export class MenuComponent implements AfterContentInit {
ngAfterContentInit() {
if (this.ariaRole() === "menu") {
- this.keyManager = new FocusKeyManager(this.menuItems())
+ this.keyManager = new FocusKeyManager(
+ this.menuItems() as unknown as (FocusableOption & MenuItemComponent)[],
+ )
.withWrap()
- .skipPredicate((item) => !!item.disabled);
+ .skipPredicate((item) => !!(item as MenuItemComponent).disabled());
}
}
}
diff --git a/libs/components/src/menu/menu.stories.ts b/libs/components/src/menu/menu.stories.ts
index 7a4f06232ef..6ef5ec4fea0 100644
--- a/libs/components/src/menu/menu.stories.ts
+++ b/libs/components/src/menu/menu.stories.ts
@@ -1,20 +1,60 @@
import { OverlayModule } from "@angular/cdk/overlay";
+import { NgTemplateOutlet } from "@angular/common";
+import { Component } from "@angular/core";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { AsyncActionsModule } from "../async-actions";
import { ButtonModule } from "../button/button.module";
+import { IconButtonModule } from "../icon-button";
import { I18nMockService } from "../utils";
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
import { MenuModule } from "./menu.module";
+const template = /*html*/ `
+
+
+
+
+
+
+
+ `;
+
+// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ template,
+ selector: "app-promise-example",
+ imports: [
+ NgTemplateOutlet,
+ AsyncActionsModule,
+ ButtonModule,
+ IconButtonModule,
+ OverlayModule,
+ MenuModule,
+ ],
+})
+class PromiseExampleComponent {
+ statusEmoji = "🟡";
+ action = async () => {
+ await new Promise((resolve, reject) => {
+ setTimeout(() => {
+ resolve();
+ this.statusEmoji = "🟢";
+ }, 5000);
+ });
+ };
+}
+
export default {
title: "Component Library/Menu",
component: MenuTriggerForDirective,
decorators: [
moduleMetadata({
- imports: [MenuModule, OverlayModule, ButtonModule],
+ imports: [MenuModule, OverlayModule, ButtonModule, PromiseExampleComponent],
providers: [
{
provide: I18nService,
@@ -86,3 +126,10 @@ export const ClosedMenu: Story = {
`,
}),
};
+
+export const InProgressMenuItem: Story = {
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts
index 52a4f59e7a2..be475f698de 100644
--- a/libs/vault/src/components/copy-cipher-field.directive.ts
+++ b/libs/vault/src/components/copy-cipher-field.directive.ts
@@ -88,7 +88,7 @@ export class CopyCipherFieldDirective implements OnChanges {
// If the directive is used on a menu item, update the menu item to prevent keyboard navigation
if (this.menuItemComponent) {
- this.menuItemComponent.disabled = this.disabled ?? false;
+ this.menuItemComponent.disabled.set(this.disabled ?? false);
}
}