1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 12:13:45 +00:00

Merge branch 'main' into km/move-keygen-ownership

This commit is contained in:
Bernd Schoolmann
2025-07-07 11:25:15 +02:00
committed by GitHub
45 changed files with 593 additions and 706 deletions

View File

@@ -44,6 +44,40 @@ describe("OverlayNotificationsContentService", () => {
expect(bodyAppendChildSpy).not.toHaveBeenCalled();
});
it("applies correct styles when notificationRefreshFlag is true", async () => {
overlayNotificationsContentService["notificationRefreshFlag"] = true;
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
typeData: mock<NotificationTypeData>(),
},
});
await flushPromises();
const barElement = overlayNotificationsContentService["notificationBarElement"]!;
expect(barElement.style.height).toBe("400px");
expect(barElement.style.right).toBe("0px");
});
it("applies correct styles when notificationRefreshFlag is false", async () => {
overlayNotificationsContentService["notificationRefreshFlag"] = false;
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
typeData: mock<NotificationTypeData>(),
},
});
await flushPromises();
const barElement = overlayNotificationsContentService["notificationBarElement"]!;
expect(barElement.style.height).toBe("82px");
expect(barElement.style.right).toBe("10px");
});
it("closes the notification bar if the notification bar type has changed", async () => {
overlayNotificationsContentService["currentNotificationBarType"] = "add";
const closeNotificationBarSpy = jest.spyOn(

View File

@@ -21,24 +21,32 @@ export class OverlayNotificationsContentService
private notificationBarIframeElement: HTMLIFrameElement | null = null;
private currentNotificationBarType: NotificationType | null = null;
private notificationRefreshFlag: boolean = false;
private notificationBarElementStyles: Partial<CSSStyleDeclaration> = {
height: "82px",
width: "430px",
maxWidth: "calc(100% - 20px)",
minHeight: "initial",
top: "10px",
right: "10px",
padding: "0",
position: "fixed",
zIndex: "2147483647",
visibility: "visible",
borderRadius: "4px",
border: "none",
backgroundColor: "transparent",
overflow: "hidden",
transition: "box-shadow 0.15s ease",
transitionDelay: "0.15s",
};
private getNotificationBarStyles(): Partial<CSSStyleDeclaration> {
const styles: Partial<CSSStyleDeclaration> = {
height: "400px",
width: "430px",
maxWidth: "calc(100% - 20px)",
minHeight: "initial",
top: "10px",
right: "0px",
padding: "0",
position: "fixed",
zIndex: "2147483647",
visibility: "visible",
borderRadius: "4px",
border: "none",
backgroundColor: "transparent",
overflow: "hidden",
transition: "box-shadow 0.15s ease",
transitionDelay: "0.15s",
};
if (!this.notificationRefreshFlag) {
styles.height = "82px";
styles.right = "10px";
}
return styles;
}
private notificationBarIframeElementStyles: Partial<CSSStyleDeclaration> = {
width: "100%",
height: "100%",
@@ -60,7 +68,6 @@ export class OverlayNotificationsContentService
void sendExtensionMessage("checkNotificationQueue");
void sendExtensionMessage("notificationRefreshFlagValue").then((notificationRefreshFlag) => {
this.notificationRefreshFlag = !!notificationRefreshFlag;
this.setNotificationRefreshBarHeight();
});
}
@@ -233,32 +240,12 @@ export class OverlayNotificationsContentService
this.notificationBarElement = globalThis.document.createElement("div");
this.notificationBarElement.id = "bit-notification-bar";
setElementStyles(this.notificationBarElement, this.notificationBarElementStyles, true);
this.setNotificationRefreshBarHeight();
setElementStyles(this.notificationBarElement, this.getNotificationBarStyles(), true);
this.notificationBarElement.appendChild(this.notificationBarIframeElement);
}
}
/**
* Sets the height of the notification bar based on the value of `notificationRefreshFlag`.
* If the flag is `true`, the bar is expanded to 400px and aligned right.
* If the flag is `false`, `null`, or `undefined`, it defaults to height of 82px.
* Skips if the notification bar element has not yet been created.
*
*/
private setNotificationRefreshBarHeight() {
const isNotificationV3 = !!this.notificationRefreshFlag;
if (!this.notificationBarElement) {
return;
}
if (isNotificationV3) {
setElementStyles(this.notificationBarElement, { height: "400px", right: "0" }, true);
}
}
/**
* Sets up the message listener for the initialization of the notification bar.
* This will send the initialization data to the notification bar iframe.

View File

@@ -27,12 +27,11 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getById } from "@bitwarden/common/platform/misc";
import { BannerModule, IconModule } from "@bitwarden/components";
import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components";
import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
import { WebLayoutModule } from "../../../layouts/web-layout.module";
import { AdminConsoleLogo } from "../../icons/admin-console-logo";
@Component({
selector: "app-organization-layout",

View File

@@ -48,7 +48,7 @@
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="(trialPaymentOptional$ | async) && trialLength === 0 && !isSecretsManagerFree"
*ngIf="showBillingStep$ | async"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"

View File

@@ -4,7 +4,7 @@ import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
import {
InputPasswordFlow,
@@ -101,6 +101,9 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
protected trialPaymentOptional$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
protected allowTrialLengthZero$ = this.configService.getFeatureFlag$(
FeatureFlag.AllowTrialLengthZero,
);
constructor(
protected router: Router,
@@ -334,6 +337,18 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
return this.productTier;
}
readonly showBillingStep$ = combineLatest([
this.trialPaymentOptional$,
this.allowTrialLengthZero$,
]).pipe(
map(([trialPaymentOptional, allowTrialLengthZero]) => {
return (
(!trialPaymentOptional && !this.isSecretsManagerFree) ||
(trialPaymentOptional && allowTrialLengthZero && this.trialLength === 0)
);
}),
);
/** Create an organization unless the trial is for secrets manager */
async conditionallyCreateOrganization(): Promise<void> {
if (!this.isSecretsManagerFree) {

View File

@@ -16,43 +16,45 @@
>
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<ng-container *ngFor="let more of moreProducts">
<!-- <a> for when the marketing route is external -->
<a
*ngIf="more.marketingRoute.external"
[href]="more.marketingRoute.route"
target="_blank"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
<div class="tw-ps-2 tw-pe-2">
<!-- <a> for when the marketing route is external -->
<a
*ngIf="more.marketingRoute.external"
[href]="more.marketingRoute.route"
target="_blank"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</div>
</a>
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
<a
*ngIf="!more.marketingRoute.external"
[routerLink]="more.marketingRoute.route"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</a>
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
<a
*ngIf="!more.marketingRoute.external"
[routerLink]="more.marketingRoute.route"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</div>
</a>
</a>
</div>
</ng-container>
</section>
</ng-container>

View File

@@ -9,11 +9,10 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { IconModule } from "@bitwarden/components";
import { IconModule, PasswordManagerLogo } from "@bitwarden/components";
import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component";
import { PasswordManagerLogo } from "./password-manager-logo";
import { WebLayoutModule } from "./web-layout.module";
@Component({

View File

@@ -22,7 +22,7 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { SecretsManagerLogo } from "@bitwarden/web-vault/app/layouts/secrets-manager-logo";
import { SecretsManagerLogo } from "@bitwarden/components";
import { OrganizationCounts } from "../models/view/counts.view";
import { ProjectService } from "../projects/project.service";

View File

@@ -33,6 +33,7 @@ export enum FeatureFlag {
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup",
UseOrganizationWarningsService = "use-organization-warnings-service",
AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
/* Data Insights and Reporting */
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
@@ -114,6 +115,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE,
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
[FeatureFlag.AllowTrialLengthZero]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -383,7 +383,7 @@ export class CipherService implements CipherServiceAbstraction {
const decCiphers = await this.getDecryptedCiphers(userId);
if (decCiphers != null && decCiphers.length !== 0) {
await this.reindexCiphers(userId);
return await this.getDecryptedCiphers(userId);
return decCiphers;
}
const decrypted = await this.decryptCiphers(await this.getAll(userId), userId);

View File

@@ -10,7 +10,8 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { IconModule, Icon } from "../icon";
import { BitwardenLogo, BitwardenShield } from "../icon/icons";
import { BitwardenLogo } from "../icon/icons";
import { BitwardenShield } from "../icon/logos";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";

View File

@@ -20,64 +20,75 @@ export class CheckboxComponent implements BitFormControlAbstraction {
"tw-cursor-pointer",
"tw-inline-block",
"tw-align-sub",
"tw-rounded",
"tw-border",
"tw-border-solid",
"tw-border-secondary-500",
"tw-h-[1.12rem]",
"tw-w-[1.12rem]",
"tw-me-1.5",
"tw-flex-none", // Flexbox fix for bit-form-control
"!tw-p-1",
"after:tw-inset-1",
// negative margin to negate the positioning added by the padding
"!-tw-mt-1",
"!-tw-mb-1",
"!-tw-ms-1",
"before:tw-content-['']",
"before:tw-block",
"before:tw-absolute",
"before:tw-inset-0",
"before:tw-h-[1.12rem]",
"before:tw-w-[1.12rem]",
"before:tw-rounded",
"before:tw-border",
"before:tw-border-solid",
"before:tw-border-secondary-500",
"hover:tw-border-2",
"[&>label]:tw-border-2",
"after:tw-content-['']",
"after:tw-block",
"after:tw-absolute",
"after:tw-inset-0",
"after:tw-h-[1.12rem]",
"after:tw-w-[1.12rem]",
"hover:before:tw-border-2",
"[&>label]:before:tw-border-2",
// if it exists, the parent form control handles focus
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-2",
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-offset-2",
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-primary-600",
"[&:not(bit-form-control_*)]:focus-visible:before:tw-ring-2",
"[&:not(bit-form-control_*)]:focus-visible:before:tw-ring-offset-2",
"[&:not(bit-form-control_*)]:focus-visible:before:tw-ring-primary-600",
"disabled:tw-cursor-auto",
"disabled:tw-border",
"disabled:hover:tw-border",
"disabled:tw-bg-secondary-100",
"disabled:hover:tw-bg-secondary-100",
"disabled:before:tw-cursor-auto",
"disabled:before:tw-border",
"disabled:before:hover:tw-border",
"disabled:before:tw-bg-secondary-100",
"disabled:hover:before:tw-bg-secondary-100",
"checked:tw-bg-primary-600",
"checked:tw-border-primary-600",
"checked:hover:tw-bg-primary-700",
"checked:hover:tw-border-primary-700",
"[&>label:hover]:checked:tw-bg-primary-700",
"[&>label:hover]:checked:tw-border-primary-700",
"checked:before:tw-bg-text-contrast",
"checked:before:tw-mask-position-[center]",
"checked:before:tw-mask-repeat-[no-repeat]",
"checked:disabled:tw-border-secondary-100",
"checked:disabled:hover:tw-border-secondary-100",
"checked:disabled:tw-bg-secondary-100",
"checked:disabled:before:tw-bg-text-muted",
"checked:before:tw-bg-primary-600",
"checked:before:tw-border-primary-600",
"checked:before:hover:tw-bg-primary-700",
"checked:before:hover:tw-border-primary-700",
"[&>label:hover]:checked:before:tw-bg-primary-700",
"[&>label:hover]:checked:before:tw-border-primary-700",
"checked:after:tw-bg-text-contrast",
"checked:after:tw-mask-position-[center]",
"checked:after:tw-mask-repeat-[no-repeat]",
"checked:disabled:before:tw-border-secondary-100",
"checked:disabled:hover:before:tw-border-secondary-100",
"checked:disabled:before:tw-bg-secondary-100",
"checked:disabled:after:tw-bg-text-muted",
"[&:not(:indeterminate)]:checked:before:tw-mask-image-[var(--mask-image)]",
"indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]",
"[&:not(:indeterminate)]:checked:after:tw-mask-image-[var(--mask-image)]",
"indeterminate:after:tw-mask-image-[var(--indeterminate-mask-image)]",
"indeterminate:tw-bg-primary-600",
"indeterminate:tw-border-primary-600",
"indeterminate:hover:tw-bg-primary-700",
"indeterminate:hover:tw-border-primary-700",
"[&>label:hover]:indeterminate:tw-bg-primary-700",
"[&>label:hover]:indeterminate:tw-border-primary-700",
"indeterminate:before:tw-bg-text-contrast",
"indeterminate:before:tw-mask-position-[center]",
"indeterminate:before:tw-mask-repeat-[no-repeat]",
"indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]",
"indeterminate:before:tw-bg-primary-600",
"indeterminate:before:tw-border-primary-600",
"indeterminate:hover:before:tw-bg-primary-700",
"indeterminate:hover:before:tw-border-primary-700",
"[&>label:hover]:indeterminate:before:tw-bg-primary-700",
"[&>label:hover]:indeterminate:before:tw-border-primary-700",
"indeterminate:after:tw-bg-text-contrast",
"indeterminate:after:tw-mask-position-[center]",
"indeterminate:after:tw-mask-repeat-[no-repeat]",
"indeterminate:after:tw-mask-image-[var(--indeterminate-mask-image)]",
"indeterminate:disabled:tw-border-secondary-100",
"indeterminate:disabled:tw-bg-secondary-100",
"indeterminate:disabled:before:tw-bg-text-muted",
"indeterminate:disabled:after:tw-bg-text-muted",
];
constructor(@Optional() @Self() private ngControl?: NgControl) {}

View File

@@ -13,6 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { BadgeModule } from "../badge";
import { FormControlModule } from "../form-control";
import { FormFieldModule } from "../form-field";
import { TableModule } from "../table";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -30,6 +31,7 @@ const template = /*html*/ `
@Component({
selector: "app-example",
template,
imports: [CheckboxModule, FormFieldModule, ReactiveFormsModule],
})
class ExampleComponent {
protected formObj = this.formBuilder.group({
@@ -55,8 +57,8 @@ export default {
title: "Component Library/Form/Checkbox",
decorators: [
moduleMetadata({
declarations: [ExampleComponent],
imports: [
ExampleComponent,
FormsModule,
ReactiveFormsModule,
FormControlModule,
@@ -195,17 +197,17 @@ export const Custom: Story = {
props: args,
template: /*html*/ `
<div class="tw-flex tw-flex-col tw-w-32">
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
<label class="tw-text-main tw-gap-2 tw-flex tw-items-center tw-justify-between tw-bg-secondary-300 tw-p-2">
A-Z
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
<input class="tw-me-0 focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
<label class="tw-text-main tw-flex tw-items-center tw-justify-between tw-bg-secondary-300 tw-p-2">
a-z
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
<input class="tw-me-0 focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
<label class="tw-text-main tw-flex tw-items-center tw-justify-between tw-bg-secondary-300 tw-p-2">
0-9
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
<input class="tw-me-0 focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
</div>
`,

View File

@@ -25,10 +25,13 @@ interface Animal {
template: `
<bit-layout>
<button class="tw-mr-2" bitButton type="button" (click)="openDialog()">Open Dialog</button>
<button class="tw-mr-2" bitButton type="button" (click)="openDialogNonDismissable()">
Open Non-Dismissable Dialog
</button>
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
</bit-layout>
`,
imports: [ButtonModule],
imports: [ButtonModule, LayoutComponent],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
@@ -41,6 +44,15 @@ class StoryDialogComponent {
});
}
openDialogNonDismissable() {
this.dialogService.open(NonDismissableContent, {
data: {
animal: "panda",
},
disableClose: true,
});
}
openDrawer() {
this.dialogService.openDrawer(StoryDialogContentComponent, {
data: {
@@ -79,13 +91,40 @@ class StoryDialogContentComponent {
}
}
@Component({
template: `
<bit-dialog title="Dialog Title" dialogSize="large">
<span bitDialogContent>
Dialog body text goes here.
<br />
Animal: {{ animal }}
</span>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">
Save
</button>
</ng-container>
</bit-dialog>
`,
imports: [DialogModule, ButtonModule],
})
class NonDismissableContent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
get animal() {
return this.data?.animal;
}
}
export default {
title: "Component Library/Dialogs/Service",
component: StoryDialogComponent,
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
ButtonModule,
@@ -138,8 +177,7 @@ export const Default: Story = {
},
};
/** Drawers must be a descendant of `bit-layout`. */
export const Drawer: Story = {
export const NonDismissable: Story = {
play: async (context) => {
const canvas = context.canvasElement;
@@ -147,3 +185,13 @@ export const Drawer: Story = {
await userEvent.click(button);
},
};
/** Drawers must be a descendant of `bit-layout`. */
export const Drawer: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[2];
await userEvent.click(button);
},
};

View File

@@ -30,15 +30,17 @@
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h2>
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
}
</header>
<div

View File

@@ -87,8 +87,10 @@ export class DialogComponent {
}
handleEsc(event: Event) {
this.dialogRef?.close();
event.stopPropagation();
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
event.stopPropagation();
}
}
get width() {

View File

@@ -2,6 +2,7 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { getAllByRole, userEvent } from "@storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -15,19 +16,45 @@ interface Animal {
}
@Component({
template: `<button type="button" bitButton (click)="openDialog()">Open Simple Dialog</button>`,
template: `
<button type="button" bitButton (click)="openSimpleDialog()">Open Simple Dialog</button>
<button type="button" bitButton (click)="openNonDismissableWithPrimaryButtonDialog()">
Open Non-Dismissable Simple Dialog with Primary Button
</button>
<button type="button" bitButton (click)="openNonDismissableWithNoButtonsDialog()">
Open Non-Dismissable Simple Dialog with No Buttons
</button>
`,
imports: [ButtonModule],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
openDialog() {
this.dialogService.open(StoryDialogContentComponent, {
openSimpleDialog() {
this.dialogService.open(SimpleDialogContent, {
data: {
animal: "panda",
},
});
}
openNonDismissableWithPrimaryButtonDialog() {
this.dialogService.open(NonDismissableWithPrimaryButtonContent, {
data: {
animal: "panda",
},
disableClose: true,
});
}
openNonDismissableWithNoButtonsDialog() {
this.dialogService.open(NonDismissableWithNoButtonsContent, {
data: {
animal: "panda",
},
disableClose: true,
});
}
}
@Component({
@@ -49,7 +76,60 @@ class StoryDialogComponent {
`,
imports: [ButtonModule, DialogModule],
})
class StoryDialogContentComponent {
class SimpleDialogContent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
get animal() {
return this.data?.animal;
}
}
@Component({
template: `
<bit-simple-dialog>
<span bitDialogTitle>Dialog Title</span>
<span bitDialogContent>
Dialog body text goes here.
<br />
Animal: {{ animal }}
</span>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">
Save
</button>
</ng-container>
</bit-simple-dialog>
`,
imports: [ButtonModule, DialogModule],
})
class NonDismissableWithPrimaryButtonContent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
get animal() {
return this.data?.animal;
}
}
@Component({
template: `
<bit-simple-dialog>
<span bitDialogTitle>Dialog Title</span>
<span bitDialogContent>
Dialog body text goes here.
<br />
Animal: {{ animal }}
</span>
</bit-simple-dialog>
`,
imports: [ButtonModule, DialogModule],
})
class NonDismissableWithNoButtonsContent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
@@ -89,4 +169,29 @@ export default {
type Story = StoryObj<StoryDialogComponent>;
export const Default: Story = {};
export const Default: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[0];
await userEvent.click(button);
},
};
export const NonDismissableWithPrimaryButton: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[1];
await userEvent.click(button);
},
};
export const NonDismissableWithNoButtons: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[2];
await userEvent.click(button);
},
};

View File

@@ -1,5 +1,5 @@
<label
class="tw-transition tw-select-none tw-mb-0 tw-inline-flex tw-rounded tw-p-0.5 has-[:focus-visible]:tw-ring has-[:focus-visible]:tw-ring-primary-600"
class="tw-transition tw-items-start [&:has(input[type='checkbox'])]:tw-gap-[.25rem] [&:has(input[type='radio'])]:tw-gap-1.5 tw-select-none tw-mb-0 tw-inline-flex tw-rounded has-[:focus-visible]:tw-ring has-[:focus-visible]:tw-ring-primary-600"
[ngClass]="[formControl.disabled ? 'tw-cursor-auto' : 'tw-cursor-pointer']"
>
<ng-content></ng-content>

View File

@@ -1,7 +0,0 @@
import { svgIcon } from "../icon";
export const BitwardenShield = svgIcon`
<svg viewBox="0 0 120 132" xmlns="http://www.w3.org/2000/svg">
<path class="tw-fill-marketing-logo" d="M82.2944 69.1899V37.2898H60V93.9624C63.948 91.869 67.4812 89.5927 70.5998 87.1338C78.3962 81.0196 82.2944 75.0383 82.2944 69.1899ZM91.8491 30.9097V69.1899C91.8491 72.0477 91.2934 74.8805 90.182 77.6883C89.0706 80.4962 87.6938 82.9884 86.0516 85.1649C84.4094 87.3415 82.452 89.4598 80.1794 91.5201C77.9068 93.5803 75.8084 95.2916 73.8842 96.654C71.96 98.0164 69.9528 99.304 67.8627 100.517C65.7726 101.73 64.288 102.552 63.4088 102.984C62.5297 103.416 61.8247 103.748 61.2939 103.981C60.8958 104.18 60.4645 104.28 60 104.28C59.5355 104.28 59.1042 104.18 58.7061 103.981C58.1753 103.748 57.4703 103.416 56.5911 102.984C55.712 102.552 54.2273 101.73 52.1372 100.517C50.0471 99.304 48.04 98.0164 46.1158 96.654C44.1916 95.2916 42.0932 93.5803 39.8206 91.5201C37.548 89.4598 35.5906 87.3415 33.9484 85.1649C32.3062 82.9884 30.9294 80.4962 29.818 77.6883C28.7066 74.8805 28.1509 72.0477 28.1509 69.1899V30.9097C28.1509 30.0458 28.4661 29.2981 29.0964 28.6668C29.7267 28.0354 30.4732 27.7197 31.3358 27.7197H88.6642C89.5268 27.7197 90.2732 28.0354 90.9036 28.6668C91.5339 29.2981 91.8491 30.0458 91.8491 30.9097Z" />
</svg>
`;

View File

@@ -1,5 +1,4 @@
export * from "./bitwarden-logo.icon";
export * from "./bitwarden-shield.icon";
export * from "./extension-bitwarden-logo.icon";
export * from "./lock.icon";
export * from "./generator";

View File

@@ -1,3 +1,4 @@
export * from "./icon.module";
export * from "./icon";
export * as Icons from "./icons";
export { AdminConsoleLogo, PasswordManagerLogo, SecretsManagerLogo } from "./logos";

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
export { default as AdminConsoleLogo } from "./admin-console";
export { default as BitwardenShield } from "./shield";
export { default as PasswordManagerLogo } from "./password-manager";
export { default as SecretsManagerLogo } from "./secrets-manager";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
import { svgIcon } from "../../icon";
const BitwardenShield = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 28 33"><path d="M26.696.403A1.274 1.274 0 0 0 25.764 0H1.83C1.467 0 1.16.137.898.403a1.294 1.294 0 0 0-.398.944v16.164c0 1.203.235 2.405.697 3.587.462 1.188 1.038 2.24 1.728 3.155.682.922 1.5 1.815 2.453 2.68a28.077 28.077 0 0 0 2.63 2.167 32.181 32.181 0 0 0 2.518 1.628c.875.511 1.493.857 1.863 1.045.37.18.661.324.882.417.163.087.348.13.54.13.192 0 .377-.043.54-.13.221-.1.52-.237.882-.417.37-.18.989-.534 1.863-1.045a34.4 34.4 0 0 0 2.517-1.628c.804-.576 1.679-1.296 2.631-2.168a20.206 20.206 0 0 0 2.454-2.68 13.599 13.599 0 0 0 1.72-3.154c.463-1.189.697-2.384.697-3.587V1.347a1.406 1.406 0 0 0-.42-.944ZM23.61 17.662c0 5.849-9.813 10.89-9.813 10.89V3.458h9.813v14.205Z" class="tw-fill-marketing-logo"/></svg>
`;
export default BitwardenShield;

View File

@@ -0,0 +1 @@
export * from "./bitwarden";

View File

@@ -55,207 +55,15 @@ export const WithContent: Story = {
template: /* HTML */ `
<bit-layout>
<bit-side-nav>
<bit-nav-item text="Item A" route="#" icon="bwi-lock"></bit-nav-item>
<bit-nav-group text="Tree A" icon="bwi-family" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B" route="b"></bit-nav-item>
<bit-nav-item text="Child C" route="c" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Tree B" icon="bwi-collection-shared" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Tree C" icon="bwi-key" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B"></bit-nav-item>
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
</bit-side-nav>
<bit-callout title="Foobar"> Hello world! </bit-callout>
@@ -277,77 +85,15 @@ export const Secondary: Story = {
template: /* HTML */ `
<bit-layout>
<bit-side-nav variant="secondary">
<bit-nav-item text="Item A" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Item B" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-divider></bit-nav-divider>
<bit-nav-item text="Item C" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Item D" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-group text="Tree example" icon="bwi-collection-shared" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection-shared"
variant="tree"
></bit-nav-item>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B" route="b"></bit-nav-item>
<bit-nav-item text="Child C" route="c" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B"></bit-nav-item>
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
</bit-side-nav>
<bit-callout title="Foobar"> Hello world! </bit-callout>

View File

@@ -55,16 +55,6 @@ export abstract class NavBaseComponent {
matrixParams: "ignored",
};
/**
* If this item is used within a tree, set `variant` to `"tree"`
*/
@Input() variant: "default" | "tree" = "default";
/**
* Depth level when nested inside of a `'tree'` variant
*/
@Input() treeDepth = 0;
/**
* If `true`, do not change styles when nav item is active.
*/

View File

@@ -1,3 +1,3 @@
@if (sideNavService.open$ | async) {
<div class="tw-h-px tw-w-full tw-bg-secondary-300"></div>
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
}

View File

@@ -6,8 +6,6 @@
[route]="route"
[relativeTo]="relativeTo"
[routerLinkActiveOptions]="routerLinkActiveOptions"
[variant]="variant"
[treeDepth]="treeDepth"
(mainContentClicked)="handleMainContentClicked()"
[ariaLabel]="ariaLabel"
[hideActiveStyles]="parentHideActiveStyles"
@@ -16,9 +14,7 @@
<button
type="button"
class="tw-ms-auto"
[bitIconButton]="
open ? 'bwi-angle-up' : variant === 'tree' ? 'bwi-angle-right' : 'bwi-angle-down'
"
[bitIconButton]="open ? 'bwi-angle-up' : 'bwi-angle-down'"
[buttonType]="'light'"
(click)="toggle($event)"
size="small"
@@ -29,17 +25,9 @@
[attr.aria-label]="['toggleCollapse' | i18n, text].join(' ')"
></button>
</ng-template>
<!-- Show toggle to the left for trees otherwise to the right -->
@if (variant === "tree") {
<ng-container slot="start">
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
}
<ng-container slot="end">
<ng-content select="[slot=end]"></ng-content>
@if (variant !== "tree") {
<ng-container *ngTemplateOutlet="button"></ng-container>
}
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
</bit-nav-item>
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->

View File

@@ -1,6 +1,5 @@
import { CommonModule } from "@angular/common";
import {
AfterContentInit,
booleanAttribute,
Component,
ContentChildren,
@@ -29,7 +28,7 @@ import { SideNavService } from "./side-nav.service";
],
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
})
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
export class NavGroupComponent extends NavBaseComponent {
@ContentChildren(NavBaseComponent, {
descendants: true,
})
@@ -80,18 +79,6 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
this.setOpen(!this.open);
}
/**
* - For any nested NavGroupComponents or NavItemComponents, increment the `treeDepth` by 1.
*/
private initNestedStyles() {
if (this.variant !== "tree") {
return;
}
[...this.nestedNavComponents].forEach((navGroupOrItem) => {
navGroupOrItem.treeDepth += 1;
});
}
protected handleMainContentClicked() {
if (!this.sideNavService.open) {
if (!this.route) {
@@ -103,8 +90,4 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
}
this.mainContentClicked.emit();
}
ngAfterContentInit(): void {
this.initNestedStyles();
}
}

View File

@@ -111,31 +111,6 @@ export const HideEmptyGroups: StoryObj<NavGroupComponent & { renderChildren: boo
}),
};
export const Tree: StoryObj<NavGroupComponent> = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-side-nav>
<bit-nav-group text="Tree example" icon="bwi-collection-shared" [open]="true">
<bit-nav-group text="Level 1 - with children (empty)" route="t1" icon="bwi-collection-shared" variant="tree"></bit-nav-group>
<bit-nav-item text="Level 1 - no children" route="t2" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 1 - with children" route="t3" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-group text="Level 2 - with children" route="t4" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-item text="Level 3 - no children, no icon" route="t5" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 3 - with children" route="t6" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-item text="Level 4 - no children, no icon" route="t7" variant="tree"></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group text="Level 2 - with children (empty)" route="t8" icon="bwi-collection-shared" variant="tree" [open]="true"></bit-nav-group>
<bit-nav-item text="Level 2 - no children" route="t9" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
</bit-nav-group>
<bit-nav-item text="Level 1 - no children" route="t10" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
</bit-nav-group>
</bit-side-nav>
`,
}),
};
export const Secondary: StoryObj<NavGroupComponent> = {
render: (args) => ({
props: args,

View File

@@ -1,113 +1,77 @@
<ng-container
*ngIf="{
open: sideNavService.open$ | async,
} as data"
>
<div
*ngIf="data.open || icon"
class="tw-relative"
[ngClass]="[
showActiveStyles
? 'tw-bg-background-alt4'
: 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
fvwStyles$ | async,
]"
>
<div class="tw-ps-2 tw-pe-2">
@let open = sideNavService.open$ | async;
@if (open || icon) {
<div
[ngStyle]="{
'padding-left': data.open ? (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem' : '0',
}"
class="tw-relative tw-flex"
class="tw-relative tw-rounded-md tw-h-10"
[ngClass]="[
showActiveStyles
? 'tw-bg-background-alt4'
: 'tw-bg-background-alt3 hover:tw-bg-hover-contrast',
fvwStyles$ | async,
]"
>
<div [ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']">
<div
#slotStart
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
>
<ng-content select="[slot=start]"></ng-content>
</div>
<!-- Default content for #slotStart (for consistent sizing) -->
<div
*ngIf="slotStart.childElementCount === 0"
[ngClass]="{
'tw-w-0': variant !== 'tree',
}"
>
<div class="tw-relative tw-flex tw-items-center tw-h-full">
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<div
[title]="text"
class="tw-truncate tw-gap-2 tw-items-center tw-font-bold"
[ngClass]="{ 'tw-text-center': !open, 'tw-flex': open }"
>
<i
class="!tw-m-0 tw-w-4 bwi bwi-fw tw-text-alt2 {{ icon }}"
[attr.aria-hidden]="open"
[attr.aria-label]="text"
></i>
@if (open) {
<span>{{ text }}</span>
}
</div>
</ng-template>
<!-- Show if a value was passed to `this.to` -->
<ng-template #isAnchor>
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="tw-w-full tw-px-3 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
data-fvw
[routerLink]="route"
[relativeTo]="relativeTo"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="routerLinkActiveOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>
</ng-template>
<!-- Show if `this.to` is falsy -->
<ng-template #isButton>
<!-- Class field should match `#isAnchor` class field above -->
<button
type="button"
class="tw-invisible"
[bitIconButton]="'bwi-angle-down'"
size="small"
aria-hidden="true"
></button>
</div>
</div>
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<div
[title]="text"
class="tw-truncate"
[ngClass]="[
variant === 'tree' ? 'tw-py-1' : 'tw-py-2',
data.open ? 'tw-pe-4' : 'tw-text-center',
]"
>
<i
class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"
[attr.aria-hidden]="data.open"
[attr.aria-label]="text"
></i
><span
*ngIf="data.open"
[ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'"
>{{ text }}</span
class="tw-w-full tw-px-3 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
data-fvw
(click)="mainContentClicked.emit()"
>
</div>
</ng-template>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</button>
</ng-template>
<!-- Show if a value was passed to `this.to` -->
<ng-template #isAnchor>
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
data-fvw
[routerLink]="route"
[relativeTo]="relativeTo"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="routerLinkActiveOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>
</ng-template>
<!-- Show if `this.to` is falsy -->
<ng-template #isButton>
<!-- Class field should match `#isAnchor` class field above -->
<button
type="button"
class="tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
data-fvw
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</button>
</ng-template>
<div
*ngIf="data.open"
class="tw-flex -tw-ms-3 tw-pe-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
>
<ng-content select="[slot=end]"></ng-content>
@if (open) {
<div
class="tw-flex tw-items-center tw-pe-1.5 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
>
<ng-content select="[slot=end]"></ng-content>
</div>
}
</div>
</div>
</div>
</ng-container>
}
</div>

View File

@@ -1,23 +1,19 @@
@if (sideNavService.open) {
<div class="tw-sticky tw-top-0 tw-z-50">
<a
[routerLink]="route"
class="tw-px-5 tw-pb-5 tw-pt-7 tw-block tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2"
[attr.aria-label]="label"
[title]="label"
routerLinkActive
[ariaCurrentWhenActive]="'page'"
>
<bit-icon [icon]="openIcon"></bit-icon>
</a>
</div>
}
@if (!sideNavService.open) {
<bit-nav-item
class="tw-block tw-pt-7"
[hideActiveStyles]="true"
[route]="route"
[icon]="closedIcon"
[text]="label"
></bit-nav-item>
}
<div
[ngClass]="{
'tw-sticky tw-top-0 tw-z-50 tw-pb-2': sideNavService.open,
'tw-pb-5': !sideNavService.open,
}"
class="tw-px-2 tw-pt-5"
>
<a
[routerLink]="route"
class="tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast [&_path]:tw-fill-text-alt2"
[ngClass]="{ '[&_svg]:tw-w-[1.687rem]': !sideNavService.open }"
[attr.aria-label]="label"
[title]="label"
routerLinkActive
[ariaCurrentWhenActive]="'page'"
>
<bit-icon [icon]="sideNavService.open ? openIcon : closedIcon"></bit-icon>
</a>
</div>

View File

@@ -1,23 +1,23 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { RouterLinkActive, RouterLink } from "@angular/router";
import { Icon } from "../icon";
import { BitIconComponent } from "../icon/icon.component";
import { BitwardenShield } from "../icon/logos";
import { NavItemComponent } from "./nav-item.component";
import { SideNavService } from "./side-nav.service";
@Component({
selector: "bit-nav-logo",
templateUrl: "./nav-logo.component.html",
imports: [RouterLinkActive, RouterLink, BitIconComponent, NavItemComponent],
imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent],
})
export class NavLogoComponent {
/** Icon that is displayed when the side nav is closed */
@Input() closedIcon = "bwi-shield";
@Input() closedIcon = BitwardenShield;
/** Icon that is displayed when the side nav is open */
@Input({ required: true }) openIcon: Icon;

View File

@@ -14,6 +14,7 @@
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)',
'--color-hover-contrast': 'var(--color-hover-default)',
}
"
[cdkTrapFocus]="data.isOverlay"

View File

@@ -29,7 +29,6 @@ export class RadioInputComponent implements BitFormControlAbstraction {
"tw-border-secondary-600",
"tw-w-[1.12rem]",
"tw-h-[1.12rem]",
"tw-me-1.5",
"tw-flex-none", // Flexbox fix for bit-form-control
"hover:tw-border-2",

View File

@@ -81,16 +81,18 @@ export const Default: Story = {
template: /* HTML */ `<bit-layout>
<bit-side-nav>
<bit-nav-group text="Password Managers" icon="bwi-collection-shared" [open]="true">
<bit-nav-group
text="Favorites"
icon="bwi-collection-shared"
variant="tree"
[open]="true"
>
<bit-nav-item text="Bitwarden" route="bitwarden"></bit-nav-item>
<bit-nav-divider></bit-nav-divider>
</bit-nav-group>
<bit-nav-item text="Virtual Scroll" route="virtual-scroll"></bit-nav-item>
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B" route="b"></bit-nav-item>
<bit-nav-item
text="Virtual Scroll"
route="virtual-scroll"
icon="bwi-filter"
></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Favorites" icon="bwi-filter">
<bit-nav-item text="Favorites Child A" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Favorites Child B"></bit-nav-item>
<bit-nav-item text="Favorites Child C" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
</bit-side-nav>
<router-outlet></router-outlet>

View File

@@ -13,7 +13,7 @@
--color-background: 255 255 255;
--color-background-alt: 243 246 249;
--color-background-alt2: 23 92 219;
--color-background-alt3: 26 65 172;
--color-background-alt3: 22 55 146;
--color-background-alt4: 2 15 102;
--color-primary-100: 219 229 246;
@@ -56,6 +56,9 @@
--color-text-alt2: 255 255 255;
--color-text-code: 192 17 118;
--color-hover-default: rgb(26 65 172 / 0.1);
--color-hover-contrast: rgb(219 229 246 / 0.15);
--color-marketing-logo: 23 93 220;
--tw-ring-offset-color: #ffffff;
@@ -124,6 +127,9 @@
--color-text-alt2: 255 255 255;
--color-text-code: 255 143 208;
--color-hover-default: rgb(170 195 239 / 0.1);
--color-hover-contrast: rgb(26 39 78 / 0.15);
--color-marketing-logo: 255 255 255;
--tw-ring-offset-color: #1f242e;

View File

@@ -83,6 +83,10 @@ module.exports = {
alt3: rgba("--color-background-alt3"),
alt4: rgba("--color-background-alt4"),
},
hover: {
default: "var(--color-hover-default)",
contrast: "var(--color-hover-contrast)",
},
"marketing-logo": rgba("--color-marketing-logo"),
illustration: {
outline: rgba("--color-illustration-outline"),

View File

@@ -1,4 +1,4 @@
<ng-container *ngIf="showNewItemSpotlight">
<ng-container *ngIf="showNewItemSpotlight$ | async">
<bit-spotlight
[title]="nudgeTitle"
[subtitle]="nudgeBody"

View File

@@ -1,27 +1,30 @@
import { CommonModule } from "@angular/common";
import { ComponentRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/sdk-internal";
import { FakeAccountService, mockAccountServiceWith } from "../../../../../common/spec";
import { NewItemNudgeComponent } from "./new-item-nudge.component";
describe("NewItemNudgeComponent", () => {
let component: NewItemNudgeComponent;
let componentRef: ComponentRef<NewItemNudgeComponent>;
let fixture: ComponentFixture<NewItemNudgeComponent>;
let i18nService: MockProxy<I18nService>;
let accountService: MockProxy<AccountService>;
let nudgesService: MockProxy<NudgesService>;
const accountService: FakeAccountService = mockAccountServiceWith("test-user-id" as UserId);
beforeEach(async () => {
i18nService = mock<I18nService>({ t: (key: string) => key });
accountService = mock<AccountService>();
nudgesService = mock<NudgesService>();
await TestBed.configureTestingModule({
@@ -37,7 +40,8 @@ describe("NewItemNudgeComponent", () => {
beforeEach(() => {
fixture = TestBed.createComponent(NewItemNudgeComponent);
component = fixture.componentInstance;
component.configType = null; // Set to null for initial state
componentRef = fixture.componentRef;
componentRef.setInput("configType", null); // Set a default type for testing
fixture.detectChanges();
});
@@ -46,13 +50,11 @@ describe("NewItemNudgeComponent", () => {
});
it("should set nudge title and body for CipherType.Login type", async () => {
component.configType = CipherType.Login;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(true);
componentRef.setInput("configType", CipherType.Login);
fixture.detectChanges();
component.showNewItemSpotlight$.subscribe((value) => {
expect(value).toEqual(true);
});
expect(component.nudgeTitle).toBe("newLoginNudgeTitle");
expect(component.nudgeBody).toBe(
"newLoginNudgeBodyOne <strong>newLoginNudgeBodyBold</strong> newLoginNudgeBodyTwo",
@@ -61,39 +63,38 @@ describe("NewItemNudgeComponent", () => {
});
it("should set nudge title and body for CipherType.Card type", async () => {
component.configType = CipherType.Card;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(true);
componentRef.setInput("configType", CipherType.Card);
fixture.detectChanges();
component.showNewItemSpotlight$.subscribe((value) => {
expect(value).toEqual(true);
});
expect(component.nudgeTitle).toBe("newCardNudgeTitle");
expect(component.nudgeBody).toBe("newCardNudgeBody");
expect(component.dismissalNudgeType).toBe(NudgeType.NewCardItemStatus);
});
it("should not show anything if spotlight has been dismissed", async () => {
component.configType = CipherType.Identity;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(false);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(false);
componentRef.setInput("configType", CipherType.Identity);
fixture.detectChanges();
component.showNewItemSpotlight$.subscribe((value) => {
expect(value).toEqual(false);
});
expect(component.dismissalNudgeType).toBe(NudgeType.NewIdentityItemStatus);
});
it("should set showNewItemSpotlight to false when user dismisses spotlight", async () => {
component.showNewItemSpotlight = true;
component.showNewItemSpotlight$ = of(true);
component.dismissalNudgeType = NudgeType.NewLoginItemStatus;
component.activeUserId = "test-user-id" as UserId;
const activeUserId = "test-user-id" as UserId;
component.activeUserId$ = of(activeUserId);
const dismissSpy = jest.spyOn(nudgesService, "dismissNudge").mockResolvedValue();
await component.dismissNewItemSpotlight();
expect(component.showNewItemSpotlight).toBe(false);
expect(dismissSpy).toHaveBeenCalledWith(NudgeType.NewLoginItemStatus, component.activeUserId);
component.showNewItemSpotlight$.subscribe((value) => {
expect(value).toEqual(false);
});
expect(dismissSpy).toHaveBeenCalledWith(NudgeType.NewLoginItemStatus, activeUserId);
});
});

View File

@@ -1,6 +1,7 @@
import { NgIf } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AsyncPipe, NgIf } from "@angular/common";
import { Component, input } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, of, switchMap } from "rxjs";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
@@ -13,12 +14,17 @@ import { CipherType } from "@bitwarden/sdk-internal";
@Component({
selector: "vault-new-item-nudge",
templateUrl: "./new-item-nudge.component.html",
imports: [NgIf, SpotlightComponent],
imports: [NgIf, SpotlightComponent, AsyncPipe],
})
export class NewItemNudgeComponent implements OnInit {
@Input({ required: true }) configType: CipherType | null = null;
activeUserId: UserId | null = null;
showNewItemSpotlight: boolean = false;
export class NewItemNudgeComponent {
configType = input.required<CipherType | null>();
activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
showNewItemSpotlight$ = combineLatest([
this.activeUserId$,
toObservable(this.configType).pipe(map((cipherType) => this.mapToNudgeType(cipherType))),
]).pipe(
switchMap(([userId, nudgeType]) => this.nudgesService.showNudgeSpotlight$(nudgeType, userId)),
);
nudgeTitle: string = "";
nudgeBody: string = "";
dismissalNudgeType: NudgeType | null = null;
@@ -29,10 +35,8 @@ export class NewItemNudgeComponent implements OnInit {
private nudgesService: NudgesService,
) {}
async ngOnInit() {
this.activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
switch (this.configType) {
mapToNudgeType(cipherType: CipherType | null): NudgeType {
switch (cipherType) {
case CipherType.Login: {
const nudgeBodyOne = this.i18nService.t("newLoginNudgeBodyOne");
const nudgeBodyBold = this.i18nService.t("newLoginNudgeBodyBold");
@@ -40,25 +44,25 @@ export class NewItemNudgeComponent implements OnInit {
this.dismissalNudgeType = NudgeType.NewLoginItemStatus;
this.nudgeTitle = this.i18nService.t("newLoginNudgeTitle");
this.nudgeBody = `${nudgeBodyOne} <strong>${nudgeBodyBold}</strong> ${nudgeBodyTwo}`;
break;
return NudgeType.NewLoginItemStatus;
}
case CipherType.Card:
this.dismissalNudgeType = NudgeType.NewCardItemStatus;
this.nudgeTitle = this.i18nService.t("newCardNudgeTitle");
this.nudgeBody = this.i18nService.t("newCardNudgeBody");
break;
return NudgeType.NewCardItemStatus;
case CipherType.Identity:
this.dismissalNudgeType = NudgeType.NewIdentityItemStatus;
this.nudgeTitle = this.i18nService.t("newIdentityNudgeTitle");
this.nudgeBody = this.i18nService.t("newIdentityNudgeBody");
break;
return NudgeType.NewIdentityItemStatus;
case CipherType.SecureNote:
this.dismissalNudgeType = NudgeType.NewNoteItemStatus;
this.nudgeTitle = this.i18nService.t("newNoteNudgeTitle");
this.nudgeBody = this.i18nService.t("newNoteNudgeBody");
break;
return NudgeType.NewNoteItemStatus;
case CipherType.SshKey: {
const sshPartOne = this.i18nService.t("newSshNudgeBodyOne");
@@ -67,25 +71,18 @@ export class NewItemNudgeComponent implements OnInit {
this.dismissalNudgeType = NudgeType.NewSshItemStatus;
this.nudgeTitle = this.i18nService.t("newSshNudgeTitle");
this.nudgeBody = `${sshPartOne} <a href="https://bitwarden.com/help/ssh-agent" class="tw-text-primary-600 tw-font-bold" target="_blank">${sshPartTwo}</a>`;
break;
return NudgeType.NewSshItemStatus;
}
default:
throw new Error("Unsupported cipher type");
}
this.showNewItemSpotlight = await this.checkHasSpotlightDismissed(
this.dismissalNudgeType as NudgeType,
this.activeUserId,
);
}
async dismissNewItemSpotlight() {
if (this.dismissalNudgeType && this.activeUserId) {
await this.nudgesService.dismissNudge(this.dismissalNudgeType, this.activeUserId as UserId);
this.showNewItemSpotlight = false;
const activeUserId = await firstValueFrom(this.activeUserId$);
if (this.dismissalNudgeType && activeUserId) {
await this.nudgesService.dismissNudge(this.dismissalNudgeType, activeUserId as UserId);
this.showNewItemSpotlight$ = of(false);
}
}
async checkHasSpotlightDismissed(nudgeType: NudgeType, userId: UserId): Promise<boolean> {
return await firstValueFrom(this.nudgesService.showNudgeSpotlight$(nudgeType, userId));
}
}

View File

@@ -25,7 +25,7 @@
"test:locales": "tsc --project ./scripts/tsconfig.json && node ./scripts/dist/test-locales.js",
"lint:dep-ownership": "tsc --project ./scripts/tsconfig.json && node ./scripts/dist/dep-ownership.js",
"docs:json": "compodoc -p ./tsconfig.json -e json -d . --disableRoutesGraph",
"storybook": "ng run components:storybook",
"storybook": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" ng run components:storybook",
"build-storybook": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" ng run components:build-storybook",
"build-storybook:ci": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" ng run components:build-storybook --webpack-stats-json",
"test-stories": "test-storybook --url http://localhost:6006",