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); } }