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,6 +1,33 @@
|
|||||||
<bit-section *ngIf="ciphers?.length > 0 || description" [disableMargin]="disableSectionMargin">
|
<bit-section *ngIf="ciphers?.length > 0 || description" [disableMargin]="disableSectionMargin">
|
||||||
<div class="tw-ml-1">
|
<ng-container *ngIf="collapsibleKey">
|
||||||
<bit-section-header>
|
<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">
|
<h2 bitTypography="h6">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h2>
|
</h2>
|
||||||
@ -12,12 +39,38 @@
|
|||||||
(click)="onRefresh.emit()"
|
(click)="onRefresh.emit()"
|
||||||
[appA11yTitle]="'refresh' | i18n"
|
[appA11yTitle]="'refresh' | i18n"
|
||||||
></button>
|
></button>
|
||||||
<span bitTypography="body2" slot="end">{{ ciphers.length }}</span>
|
<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>
|
</bit-section-header>
|
||||||
</div>
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #descriptionText>
|
||||||
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
|
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #itemGroup>
|
||||||
<bit-item-group>
|
<bit-item-group>
|
||||||
<cdk-virtual-scroll-viewport
|
<cdk-virtual-scroll-viewport
|
||||||
[itemSize]="itemHeight$ | async"
|
[itemSize]="itemHeight$ | async"
|
||||||
@ -85,4 +138,4 @@
|
|||||||
</bit-item>
|
</bit-item>
|
||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
</bit-item-group>
|
</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
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
import { CdkVirtualScrollViewport, ScrollingModule } from "@angular/cdk/scrolling";
|
||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
@ -9,8 +9,11 @@ import {
|
|||||||
EventEmitter,
|
EventEmitter,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
|
Signal,
|
||||||
signal,
|
signal,
|
||||||
|
ViewChild,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { Router, RouterLink } from "@angular/router";
|
import { Router, RouterLink } from "@angular/router";
|
||||||
import { map } from "rxjs";
|
import { map } from "rxjs";
|
||||||
@ -25,6 +28,8 @@ import {
|
|||||||
BadgeModule,
|
BadgeModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
CompactModeService,
|
CompactModeService,
|
||||||
|
DisclosureComponent,
|
||||||
|
DisclosureTriggerForDirective,
|
||||||
DialogService,
|
DialogService,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
ItemModule,
|
ItemModule,
|
||||||
@ -41,6 +46,7 @@ import {
|
|||||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||||
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
|
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
|
||||||
|
import { VaultPopupSectionService } from "../../../services/vault-popup-section.service";
|
||||||
import { PopupCipherView } from "../../../views/popup-cipher.view";
|
import { PopupCipherView } from "../../../views/popup-cipher.view";
|
||||||
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
|
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
|
||||||
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.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,
|
ItemMoreOptionsComponent,
|
||||||
OrgIconDirective,
|
OrgIconDirective,
|
||||||
ScrollingModule,
|
ScrollingModule,
|
||||||
|
DisclosureComponent,
|
||||||
|
DisclosureTriggerForDirective,
|
||||||
DecryptionFailureDialogComponent,
|
DecryptionFailureDialogComponent,
|
||||||
],
|
],
|
||||||
selector: "app-vault-list-items-container",
|
selector: "app-vault-list-items-container",
|
||||||
templateUrl: "vault-list-items-container.component.html",
|
templateUrl: "vault-list-items-container.component.html",
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class VaultListItemsContainerComponent implements AfterViewInit {
|
export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
|
||||||
private compactModeService = inject(CompactModeService);
|
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.
|
* The class used to set the height of a bit item's inner content.
|
||||||
@ -106,6 +123,15 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
|||||||
@Input()
|
@Input()
|
||||||
title: string;
|
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
|
* Optional description for the vault list item section. Will be shown below the title even when
|
||||||
* no ciphers are available.
|
* no ciphers are available.
|
||||||
@ -168,6 +194,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
|||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!this.collapsibleKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sectionOpenState = this.vaultPopupSectionService.getOpenDisplayStateForSection(
|
||||||
|
this.collapsibleKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async ngAfterViewInit() {
|
async ngAfterViewInit() {
|
||||||
const autofillShortcut = await this.platformUtilsService.getAutofillKeyboardShortcut();
|
const autofillShortcut = await this.platformUtilsService.getAutofillKeyboardShortcut();
|
||||||
|
|
||||||
@ -239,4 +275,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
|||||||
cipher.canLaunch ? 200 : 0,
|
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"
|
[title]="'favorites' | i18n"
|
||||||
[ciphers]="favoriteCiphers$ | async"
|
[ciphers]="favoriteCiphers$ | async"
|
||||||
id="favorites"
|
id="favorites"
|
||||||
|
collapsibleKey="favorites"
|
||||||
></app-vault-list-items-container>
|
></app-vault-list-items-container>
|
||||||
<app-vault-list-items-container
|
<app-vault-list-items-container
|
||||||
[title]="'allItems' | i18n"
|
[title]="'allItems' | i18n"
|
||||||
[ciphers]="remainingCiphers$ | async"
|
[ciphers]="remainingCiphers$ | async"
|
||||||
id="allItems"
|
id="allItems"
|
||||||
disableSectionMargin
|
disableSectionMargin
|
||||||
|
collapsibleKey="allItems"
|
||||||
></app-vault-list-items-container>
|
></app-vault-list-items-container>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</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">
|
<div class="[&>*]:tw-mb-0 [&>*]:tw-text-main tw-flex tw-items-center tw-gap-1">
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</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>
|
<ng-content select="[slot=end]"></ng-content>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user