diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 4ea69404024..0ff2db480c1 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -5806,6 +5806,12 @@
"upgradeToPremium": {
"message": "Upgrade to Premium"
},
+ "loadingVault": {
+ "message": "Loading vault"
+ },
+ "vaultLoaded": {
+ "message": "Vault loaded"
+ },
"settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.",
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html
index a9184a9dd54..828d9947373 100644
--- a/apps/browser/src/platform/popup/layout/popup-page.component.html
+++ b/apps/browser/src/platform/popup/layout/popup-page.component.html
@@ -27,10 +27,10 @@
data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)"
[ngClass]="{
- 'tw-overflow-hidden': hideOverflow(),
+ '!tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(),
'tw-invisible': loading(),
- 'tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
+ 'tw-relative tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
!disablePadding(),
}"
bitScrollLayoutHost
diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.html b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.html
new file mode 100644
index 00000000000..c83c1ab85c4
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.html
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.ts b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.ts
new file mode 100644
index 00000000000..2426153ad68
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.ts
@@ -0,0 +1,20 @@
+import { animate, style, transition, trigger } from "@angular/animations";
+import { ChangeDetectionStrategy, Component, HostBinding } from "@angular/core";
+
+@Component({
+ selector: "vault-fade-in-out-skeleton",
+ templateUrl: "./vault-fade-in-out-skeleton.component.html",
+ animations: [
+ trigger("fadeInOut", [
+ transition(":enter", [
+ style({ opacity: 0 }),
+ animate("100ms ease-in", style({ opacity: 1 })),
+ ]),
+ transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))]),
+ ]),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class VaultFadeInOutSkeletonComponent {
+ @HostBinding("@fadeInOut") fadeInOut = true;
+}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts
index 9564aeadc09..e6afc69b56a 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts
@@ -28,6 +28,7 @@ import {
PopupListFilter,
VaultPopupListFiltersService,
} from "../../../../../vault/popup/services/vault-popup-list-filters.service";
+import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
import { VaultHeaderV2Component } from "./vault-header-v2.component";
@@ -99,6 +100,10 @@ describe("VaultHeaderV2Component", () => {
provide: StateProvider,
useValue: { getGlobal: () => ({ state$, update }) },
},
+ {
+ provide: VaultPopupLoadingService,
+ useValue: { loading$: new BehaviorSubject(false) },
+ },
],
}).compileComponents();
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html
index 224eaccd93c..68e5baac5f3 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html
@@ -4,5 +4,6 @@
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
+ [disabled]="loading$ | async"
>
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts
index c254c290915..afe71404717 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts
@@ -9,6 +9,7 @@ import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/sea
import { SearchModule } from "@bitwarden/components";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
+import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -22,8 +23,10 @@ export class VaultV2SearchComponent {
private searchText$ = new Subject();
+ protected loading$ = this.vaultPopupLoadingService.loading$;
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
+ private vaultPopupLoadingService: VaultPopupLoadingService,
private ngZone: NgZone,
) {
this.subscribeToLatestSearchText();
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
index 07d3f042e60..5bca9cddd4f 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
@@ -1,4 +1,4 @@
-
+
@@ -103,4 +103,10 @@
>
+
+ @if (showSkeletonsLoaders$ | async) {
+
+
+
+ }
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
index 2dd6c1a0ce1..e55a702d350 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
@@ -1,3 +1,4 @@
+import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
@@ -5,14 +6,15 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import {
combineLatest,
+ distinctUntilChanged,
filter,
firstValueFrom,
map,
Observable,
shareReplay,
- startWith,
switchMap,
take,
+ tap,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -22,6 +24,8 @@ import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -41,11 +45,13 @@ import { PopOutComponent } from "../../../../platform/popup/components/pop-out.c
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { IntroCarouselService } from "../../services/intro-carousel.service";
-import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
+import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service";
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
+import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component";
+import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component";
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
import {
@@ -88,6 +94,8 @@ type VaultState = UnionOfValues;
SpotlightComponent,
RouterModule,
TypographyModule,
+ VaultLoadingSkeletonComponent,
+ VaultFadeInOutSkeletonComponent,
],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
@@ -108,19 +116,30 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
);
activeUserId: UserId | null = null;
+
+ private loading$ = this.vaultPopupLoadingService.loading$.pipe(
+ distinctUntilChanged(),
+ tap((loading) => {
+ const key = loading ? "loadingVault" : "vaultLoaded";
+ void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
+ }),
+ );
+ private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
+ FeatureFlag.VaultLoadingSkeletons,
+ );
+
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
- protected loading$ = combineLatest([
- this.vaultPopupItemsService.loading$,
- this.allFilters$,
- // Added as a dependency to avoid flashing the copyActions on slower devices
- this.vaultCopyButtonsService.showQuickCopyActions$,
- ]).pipe(
- map(([itemsLoading, filters]) => itemsLoading || !filters),
- shareReplay({ bufferSize: 1, refCount: true }),
- startWith(true),
+ /** When true, show spinner loading state */
+ protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
+ map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
+ );
+
+ /** When true, show skeleton loading state */
+ protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
+ map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled),
);
protected newItemItemValues$: Observable =
@@ -150,14 +169,17 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService,
private vaultScrollPositionService: VaultPopupScrollPositionService,
+ private vaultPopupLoadingService: VaultPopupLoadingService,
private accountService: AccountService,
private destroyRef: DestroyRef,
private cipherService: CipherService,
private dialogService: DialogService,
- private vaultCopyButtonsService: VaultPopupCopyButtonsService,
private introCarouselService: IntroCarouselService,
private nudgesService: NudgesService,
private router: Router,
+ private liveAnnouncer: LiveAnnouncer,
+ private i18nService: I18nService,
+ private configService: ConfigService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
diff --git a/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts
new file mode 100644
index 00000000000..4b9c284b3b7
--- /dev/null
+++ b/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts
@@ -0,0 +1,72 @@
+import { TestBed } from "@angular/core/testing";
+import { firstValueFrom, skip, Subject } from "rxjs";
+
+import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service";
+import { VaultPopupItemsService } from "./vault-popup-items.service";
+import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
+import { VaultPopupLoadingService } from "./vault-popup-loading.service";
+
+describe("VaultPopupLoadingService", () => {
+ let service: VaultPopupLoadingService;
+ let itemsLoading$: Subject;
+ let allFilters$: Subject;
+ let showQuickCopyActions$: Subject;
+
+ beforeEach(() => {
+ itemsLoading$ = new Subject();
+ allFilters$ = new Subject();
+ showQuickCopyActions$ = new Subject();
+
+ TestBed.configureTestingModule({
+ providers: [
+ VaultPopupLoadingService,
+ { provide: VaultPopupItemsService, useValue: { loading$: itemsLoading$ } },
+ { provide: VaultPopupListFiltersService, useValue: { allFilters$: allFilters$ } },
+ {
+ provide: VaultPopupCopyButtonsService,
+ useValue: { showQuickCopyActions$: showQuickCopyActions$ },
+ },
+ ],
+ });
+
+ service = TestBed.inject(VaultPopupLoadingService);
+ });
+
+ it("emits true initially", async () => {
+ const loading = await firstValueFrom(service.loading$);
+
+ expect(loading).toBe(true);
+ });
+
+ it("emits false when items are loaded and filters are available", async () => {
+ const loadingPromise = firstValueFrom(service.loading$.pipe(skip(1)));
+
+ itemsLoading$.next(false);
+ allFilters$.next({});
+ showQuickCopyActions$.next(true);
+
+ expect(await loadingPromise).toBe(false);
+ });
+
+ it("emits true when filters are not available", async () => {
+ const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2)));
+
+ itemsLoading$.next(false);
+ allFilters$.next({});
+ showQuickCopyActions$.next(true);
+ allFilters$.next(null);
+
+ expect(await loadingPromise).toBe(true);
+ });
+
+ it("emits true when items are loading", async () => {
+ const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2)));
+
+ itemsLoading$.next(false);
+ allFilters$.next({});
+ showQuickCopyActions$.next(true);
+ itemsLoading$.next(true);
+
+ expect(await loadingPromise).toBe(true);
+ });
+});
diff --git a/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts b/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts
new file mode 100644
index 00000000000..f56f2b8d8ee
--- /dev/null
+++ b/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts
@@ -0,0 +1,27 @@
+import { inject, Injectable } from "@angular/core";
+import { combineLatest, map, shareReplay, startWith } from "rxjs";
+
+import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service";
+import { VaultPopupItemsService } from "./vault-popup-items.service";
+import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
+
+@Injectable({
+ providedIn: "root",
+})
+export class VaultPopupLoadingService {
+ private vaultPopupItemsService = inject(VaultPopupItemsService);
+ private vaultPopupListFiltersService = inject(VaultPopupListFiltersService);
+ private vaultCopyButtonsService = inject(VaultPopupCopyButtonsService);
+
+ /** Loading state of the vault */
+ loading$ = combineLatest([
+ this.vaultPopupItemsService.loading$,
+ this.vaultPopupListFiltersService.allFilters$,
+ // Added as a dependency to avoid flashing the copyActions on slower devices
+ this.vaultCopyButtonsService.showQuickCopyActions$,
+ ]).pipe(
+ map(([itemsLoading, filters]) => itemsLoading || !filters),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ startWith(true),
+ );
+}