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:
parent
fb4d7e8f05
commit
8062475044
@ -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>
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user