diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index b5d92a386b..293bf3e67e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,23 +1,76 @@ -
- -

- {{ title }} -

- - {{ ciphers.length }} -
-
+ + + + + + + + +
+ +
+ + +
+
+ + + +

+ {{ title }} +

+ + + + {{ ciphers.length }} + + + + + +
+
+ +
{{ description }}
+
+ + - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 5bcdaf56bb..579cbf6692 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ScrollingModule } from "@angular/cdk/scrolling"; +import { CdkVirtualScrollViewport, ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { AfterViewInit, @@ -9,8 +9,11 @@ import { EventEmitter, inject, Input, + OnInit, Output, + Signal, signal, + ViewChild, } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { map } from "rxjs"; @@ -25,6 +28,8 @@ import { BadgeModule, ButtonModule, CompactModeService, + DisclosureComponent, + DisclosureTriggerForDirective, DialogService, IconButtonModule, ItemModule, @@ -41,6 +46,7 @@ import { import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupSectionService } from "../../../services/vault-popup-section.service"; import { PopupCipherView } from "../../../views/popup-cipher.view"; import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; @@ -61,14 +67,25 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options ItemMoreOptionsComponent, OrgIconDirective, ScrollingModule, + DisclosureComponent, + DisclosureTriggerForDirective, DecryptionFailureDialogComponent, ], selector: "app-vault-list-items-container", templateUrl: "vault-list-items-container.component.html", standalone: true, }) -export class VaultListItemsContainerComponent implements AfterViewInit { +export class VaultListItemsContainerComponent implements OnInit, AfterViewInit { private compactModeService = inject(CompactModeService); + private vaultPopupSectionService = inject(VaultPopupSectionService); + + @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort: CdkVirtualScrollViewport; + @ViewChild(DisclosureComponent) disclosure: DisclosureComponent; + + /** + * Indicates whether the section should be open or closed if collapsibleKey is provided + */ + protected sectionOpenState: Signal | undefined; /** * The class used to set the height of a bit item's inner content. @@ -106,6 +123,15 @@ export class VaultListItemsContainerComponent implements AfterViewInit { @Input() title: string; + /** + * Optionally allow the items to be collapsed. + * + * The key must be added to the state definition in `vault-popup-section.service.ts` since the + * collapsed state is stored locally. + */ + @Input() + collapsibleKey: "favorites" | "allItems" | undefined; + /** * Optional description for the vault list item section. Will be shown below the title even when * no ciphers are available. @@ -168,6 +194,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit { private dialogService: DialogService, ) {} + ngOnInit(): void { + if (!this.collapsibleKey) { + return; + } + + this.sectionOpenState = this.vaultPopupSectionService.getOpenDisplayStateForSection( + this.collapsibleKey, + ); + } + async ngAfterViewInit() { const autofillShortcut = await this.platformUtilsService.getAutofillKeyboardShortcut(); @@ -239,4 +275,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit { cipher.canLaunch ? 200 : 0, ); } + + /** + * Update section open/close state based on user action + */ + async toggleSectionOpen() { + if (!this.collapsibleKey) { + return; + } + + await this.vaultPopupSectionService.updateSectionOpenStoredState( + this.collapsibleKey, + this.disclosure.open, + ); + } + + /** + * Force virtual scroll to update its viewport size to avoid display bugs + * + * Angular CDK scroll has a bug when used with conditional rendering: + * https://github.com/angular/components/issues/24362 + */ + protected rerenderViewport() { + setTimeout(() => { + this.viewPort.checkViewportSize(); + }); + } } 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 4798ddf4df..c5a3c860c8 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 @@ -66,12 +66,14 @@ [title]="'favorites' | i18n" [ciphers]="favoriteCiphers$ | async" id="favorites" + collapsibleKey="favorites" > diff --git a/apps/browser/src/vault/popup/services/vault-popup-section.service.ts b/apps/browser/src/vault/popup/services/vault-popup-section.service.ts new file mode 100644 index 0000000000..ed641e0cdf --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-section.service.ts @@ -0,0 +1,129 @@ +import { computed, effect, inject, Injectable, signal, Signal } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; + +import { + KeyDefinition, + StateProvider, + VAULT_SETTINGS_DISK, +} from "@bitwarden/common/platform/state"; + +import { VaultPopupItemsService } from "./vault-popup-items.service"; + +export type PopupSectionOpen = { + favorites: boolean; + allItems: boolean; +}; + +const SECTION_OPEN_KEY = new KeyDefinition(VAULT_SETTINGS_DISK, "sectionOpen", { + deserializer: (obj) => obj, +}); + +const INITIAL_OPEN: PopupSectionOpen = { + favorites: true, + allItems: true, +}; + +@Injectable({ + providedIn: "root", +}) +export class VaultPopupSectionService { + private vaultPopupItemsService = inject(VaultPopupItemsService); + private stateProvider = inject(StateProvider); + + private hasFilterOrSearchApplied = toSignal( + this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => hasFilter)), + ); + + /** + * Used to change the open/close state without persisting it to the local disk. Reflects + * application-applied overrides. + * `null` means there is no current override + */ + private temporaryStateOverride = signal | null>(null); + + constructor() { + effect( + () => { + /** + * auto-open all sections when search or filter is applied, and remove + * override when search or filter is removed + */ + if (this.hasFilterOrSearchApplied()) { + this.temporaryStateOverride.set(INITIAL_OPEN); + } else { + this.temporaryStateOverride.set(null); + } + }, + { + allowSignalWrites: true, + }, + ); + } + + /** + * Stored disk state for the open/close state of the sections. Will be `null` if user has never + * opened/closed a section + */ + private sectionOpenStateProvider = this.stateProvider.getGlobal(SECTION_OPEN_KEY); + + /** + * Stored disk state for the open/close state of the sections, with an initial value provided + * if the stored disk state does not yet exist. + */ + private sectionOpenStoredState = toSignal( + this.sectionOpenStateProvider.state$.pipe(map((sectionOpen) => sectionOpen ?? INITIAL_OPEN)), + // Indicates that the state value is loading + { initialValue: null }, + ); + + /** + * Indicates the current open/close display state of each section, accounting for temporary + * non-persisted overrides. + */ + sectionOpenDisplayState: Signal> = computed(() => ({ + ...this.sectionOpenStoredState(), + ...this.temporaryStateOverride(), + })); + + /** + * Retrieve the open/close display state for a given section. + * + * @param sectionKey section key + */ + getOpenDisplayStateForSection(sectionKey: keyof PopupSectionOpen): Signal { + return computed(() => this.sectionOpenDisplayState()?.[sectionKey]); + } + + /** + * Updates the stored open/close state of a given section. Should be called only when a user action + * is taken directly to change the open/close state. + * + * Removes any current temporary override for the given section, as direct user action should + * supersede any application-applied overrides. + * + * @param sectionKey section key + */ + async updateSectionOpenStoredState( + sectionKey: keyof PopupSectionOpen, + open: boolean, + ): Promise { + await this.sectionOpenStateProvider.update((currentState) => { + return { + ...(currentState ?? INITIAL_OPEN), + [sectionKey]: open, + }; + }); + + this.temporaryStateOverride.update((prev) => { + if (prev !== null) { + return { + ...prev, + [sectionKey]: open, + }; + } + + return prev; + }); + } +} diff --git a/libs/components/src/section/section-header.component.html b/libs/components/src/section/section-header.component.html index 3f96e22540..f070cfeae0 100644 --- a/libs/components/src/section/section-header.component.html +++ b/libs/components/src/section/section-header.component.html @@ -2,7 +2,7 @@
-
+