1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-14 20:01:31 +01:00

[PM-16243] Allow users to collapse extension sections (#12756)

This commit is contained in:
Victoria League 2025-01-13 10:37:26 -05:00 committed by GitHub
parent fb4d7e8f05
commit 8062475044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 266 additions and 20 deletions

View File

@ -1,23 +1,76 @@
<bit-section *ngIf="ciphers?.length > 0 || description" [disableMargin]="disableSectionMargin">
<div class="tw-ml-1">
<bit-section-header>
<h2 bitTypography="h6">
{{ title }}
</h2>
<button
*ngIf="showRefresh"
bitIconButton="bwi-refresh"
type="button"
size="small"
(click)="onRefresh.emit()"
[appA11yTitle]="'refresh' | i18n"
></button>
<span bitTypography="body2" slot="end">{{ ciphers.length }}</span>
</bit-section-header>
</div>
<ng-container *ngIf="collapsibleKey">
<button
class="tw-group/vault-section-header hover:tw-bg-secondary-100 tw-pl-1 tw-w-full tw-border-x-0 tw-border-t-0 tw-border-b tw-border-solid focus-visible:tw-outline-none focus-visible:tw-rounded-md focus-visible:tw-ring-inset focus-visible:tw-ring-2 focus-visible:tw-ring-primary-600"
[ngClass]="{
'tw-border-b-secondary-300': !sectionOpenState(),
'tw-border-b-transparent': sectionOpenState(),
}"
type="button"
[bitDisclosureTriggerFor]="disclosureRef"
(click)="toggleSectionOpen()"
>
<ng-container *ngTemplateOutlet="sectionHeader"></ng-container>
</button>
<ng-container *ngTemplateOutlet="descriptionText"></ng-container>
<bit-disclosure #disclosureRef [open]="sectionOpenState()" (openChange)="rerenderViewport()">
<ng-container *ngTemplateOutlet="itemGroup"></ng-container>
</bit-disclosure>
</ng-container>
<ng-container *ngIf="!collapsibleKey">
<div class="tw-pl-1">
<ng-container *ngTemplateOutlet="sectionHeader"></ng-container>
</div>
<ng-container *ngTemplateOutlet="descriptionText"></ng-container>
<ng-container *ngTemplateOutlet="itemGroup"></ng-container>
</ng-container>
</bit-section>
<ng-template #sectionHeader>
<bit-section-header class="tw-p-0.5 -tw-mt-0.5 -tw-mx-0.5">
<h2 bitTypography="h6">
{{ title }}
</h2>
<button
*ngIf="showRefresh"
bitIconButton="bwi-refresh"
type="button"
size="small"
(click)="onRefresh.emit()"
[appA11yTitle]="'refresh' | i18n"
></button>
<span bitTypography="body2" slot="end">
<span
[ngClass]="{
'group-hover/vault-section-header:tw-hidden group-focus-visible/vault-section-header:tw-hidden':
collapsibleKey && sectionOpenState(),
'tw-hidden': collapsibleKey && !sectionOpenState(),
}"
>
{{ ciphers.length }}
</span>
<span class="tw-pr-1" *ngIf="collapsibleKey">
<i
class="bwi"
[ngClass]="{
'bwi-angle-down tw-inline-block': !sectionOpenState(),
'bwi-angle-up tw-hidden group-hover/vault-section-header:tw-inline-block group-focus-visible/vault-section-header:tw-inline-block':
sectionOpenState(),
}"
aria-hidden="true"
></i>
</span>
</span>
</bit-section-header>
</ng-template>
<ng-template #descriptionText>
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
{{ description }}
</div>
</ng-template>
<ng-template #itemGroup>
<bit-item-group>
<cdk-virtual-scroll-viewport
[itemSize]="itemHeight$ | async"
@ -85,4 +138,4 @@
</bit-item>
</cdk-virtual-scroll-viewport>
</bit-item-group>
</bit-section>
</ng-template>

View File

@ -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<boolean> | 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();
});
}
}

View File

@ -66,12 +66,14 @@
[title]="'favorites' | i18n"
[ciphers]="favoriteCiphers$ | async"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="remainingCiphers$ | async"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
</div>
</ng-container>

View File

@ -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<PopupSectionOpen>(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<Partial<PopupSectionOpen> | 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<PopupSectionOpen | null>(
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<Partial<PopupSectionOpen>> = computed(() => ({
...this.sectionOpenStoredState(),
...this.temporaryStateOverride(),
}));
/**
* Retrieve the open/close display state for a given section.
*
* @param sectionKey section key
*/
getOpenDisplayStateForSection(sectionKey: keyof PopupSectionOpen): Signal<boolean | undefined> {
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<void> {
await this.sectionOpenStateProvider.update((currentState) => {
return {
...(currentState ?? INITIAL_OPEN),
[sectionKey]: open,
};
});
this.temporaryStateOverride.update((prev) => {
if (prev !== null) {
return {
...prev,
[sectionKey]: open,
};
}
return prev;
});
}
}

View File

@ -2,7 +2,7 @@
<div class="[&>*]:tw-mb-0 [&>*]:tw-text-main tw-flex tw-items-center tw-gap-1">
<ng-content></ng-content>
</div>
<div class="tw-text-muted has-[button]:-tw-mb-1">
<div class="tw-text-muted -tw-mb-0.5">
<ng-content select="[slot=end]"></ng-content>
</div>
</div>