mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 13:40:06 +00:00
allow bitAction to work with menu items
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
<div class="tw-flex tw-w-full tw-justify-between tw-items-center tw-gap-2">
|
||||
<span class="tw-flex tw-gap-2 tw-items-center tw-overflow-hidden">
|
||||
<span #startSlot [ngClass]="{ 'tw-hidden': startSlot.childElementCount === 0 }">
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
</span>
|
||||
@if (loading()) {
|
||||
<bit-spinner size="small"></bit-spinner>
|
||||
} @else {
|
||||
<span #startSlot [ngClass]="{ 'tw-hidden': startSlot.childElementCount === 0 && !loading() }">
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
</span>
|
||||
}
|
||||
<span class="tw-truncate"><ng-content></ng-content></span>
|
||||
</span>
|
||||
<span #endSlot [ngClass]="{ 'tw-hidden': endSlot.childElementCount === 0 }">
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
readonly loading = model<boolean>(false);
|
||||
readonly elementRef = inject(ElementRef<HTMLButtonElement>);
|
||||
|
||||
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<HTMLButtonElement>) {}
|
||||
|
||||
focus() {
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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*/ `
|
||||
<div class="tw-h-40">
|
||||
<button type="button" bitButton buttonType="secondary" [bitMenuTriggerFor]="myMenu">Open menu</button>
|
||||
</div>
|
||||
|
||||
<bit-menu #myMenu>
|
||||
<button type="button" bitMenuItem [bitAction]="action">Perform action {{ statusEmoji }}</button>
|
||||
</bit-menu>
|
||||
`;
|
||||
|
||||
// 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<void>((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 = {
|
||||
</bit-menu>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const InProgressMenuItem: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-promise-example></app-promise-example>`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user