From a0429d7d094f3d7267d43596a84afa6f88b73784 Mon Sep 17 00:00:00 2001 From: Will Martin Date: Thu, 15 May 2025 10:32:52 -0400 Subject: [PATCH] [CL-622][CL-562][CL-621][CL-632] various drawer improvements (#14120) - add focus trap to drawers - add config to open existing dialogs as drawers - make drawer take up fill width/height on smaller screens --- .../manage/groups.component.html | 2 +- .../members/members.component.html | 2 +- .../organizations/members/members.module.ts | 2 + .../organizations/organization.module.ts | 3 + .../collection-dialog.component.ts | 2 +- .../device-management.component.spec.ts | 14 +- .../add-sponsorship-dialog.component.ts | 4 +- .../free-bitwarden-families.component.ts | 3 +- .../vault-items/vault-items.component.html | 2 +- .../vault-items/vault-items.module.ts | 3 +- .../vault-items/vault-items.stories.ts | 12 +- .../providers/manage/members.component.html | 2 +- .../providers/providers.module.ts | 3 +- .../clients/create-client-dialog.component.ts | 4 +- .../src/dialog/dialog.service.stories.ts | 57 ++++- libs/components/src/dialog/dialog.service.ts | 240 +++++++++++++----- .../src/dialog/dialog/dialog.component.html | 40 ++- .../src/dialog/dialog/dialog.component.ts | 37 ++- libs/components/src/dialog/dialogs.mdx | 3 + libs/components/src/dialog/index.ts | 2 +- .../src/drawer/drawer-body.component.ts | 18 +- .../components/src/drawer/drawer.component.ts | 4 +- libs/components/src/drawer/drawer.mdx | 2 + libs/components/src/drawer/drawer.service.ts | 20 ++ libs/components/src/layout/index.ts | 1 + .../src/layout/layout.component.html | 89 ++++--- .../components/src/layout/layout.component.ts | 26 +- .../src/layout/scroll-layout.directive.ts | 35 +++ .../dialog-virtual-scroll-block.component.ts | 12 +- .../components/kitchen-sink-main.component.ts | 133 +++++----- .../kitchen-sink/kitchen-sink.stories.ts | 30 +-- .../src/table/table-scroll.component.html | 2 +- .../src/table/table-scroll.component.ts | 3 + libs/components/src/table/table.mdx | 12 +- libs/components/src/table/table.stories.ts | 68 +++-- .../components/src/utils/has-scrolled-from.ts | 41 +++ 36 files changed, 647 insertions(+), 286 deletions(-) create mode 100644 libs/components/src/drawer/drawer.service.ts create mode 100644 libs/components/src/layout/scroll-layout.directive.ts create mode 100644 libs/components/src/utils/has-scrolled-from.ts diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.html b/apps/web/src/app/admin-console/organizations/manage/groups.component.html index caae23f500..4518513ba7 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.html @@ -22,7 +22,7 @@

{{ "noGroupsInList" | i18n }}

- + diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 2162e33081..690e7aaa85 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -67,7 +67,7 @@ - + diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 81697f8c84..98431758d2 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -3,6 +3,7 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; +import { ScrollLayoutDirective } from "@bitwarden/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; @@ -27,6 +28,7 @@ import { MembersComponent } from "./members.component"; PasswordCalloutComponent, ScrollingModule, PasswordStrengthV2Component, + ScrollLayoutDirective, ], declarations: [ BulkConfirmDialogComponent, diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 459948d0f1..687361760c 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -1,6 +1,8 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; +import { ScrollLayoutDirective } from "@bitwarden/components"; + import { LooseComponentsModule } from "../../shared"; import { CoreOrganizationModule } from "./core"; @@ -18,6 +20,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; OrganizationsRoutingModule, LooseComponentsModule, ScrollingModule, + ScrollLayoutDirective, ], declarations: [GroupsComponent, GroupAddEditComponent], }) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 07bff3aba6..9806f99d90 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -613,5 +613,5 @@ export function openCollectionDialog( dialogService: DialogService, config: DialogConfig>, ) { - return dialogService.open(CollectionDialogComponent, config); + return dialogService.open(CollectionDialogComponent, config); } diff --git a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts b/apps/web/src/app/auth/settings/security/device-management.component.spec.ts index 84c1dfcb63..d86123f52b 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts +++ b/apps/web/src/app/auth/settings/security/device-management.component.spec.ts @@ -9,7 +9,13 @@ import { DeviceType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; -import { DialogService, ToastService, TableModule, PopoverModule } from "@bitwarden/components"; +import { + DialogService, + ToastService, + TableModule, + PopoverModule, + LayoutComponent, +} from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service"; @@ -115,6 +121,12 @@ describe("DeviceManagementComponent", () => { showError: jest.fn(), }, }, + { + provide: LayoutComponent, + useValue: { + mainContent: jest.fn(), + }, + }, ], }).compileComponents(); diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts index 7e6c0d464c..d0eb065b74 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts @@ -1,4 +1,3 @@ -import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; import { AbstractControl, @@ -19,7 +18,10 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrgKey } from "@bitwarden/common/types/key"; import { + DialogRef, ButtonModule, + DialogConfig, + DIALOG_DATA, DialogModule, DialogService, FormFieldModule, diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index b482007e30..02e4e6a9cc 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -1,4 +1,3 @@ -import { DialogRef } from "@angular/cdk/dialog"; import { formatDate } from "@angular/common"; import { Component, OnInit, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -16,7 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { StateProvider } from "@bitwarden/common/platform/state"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component"; diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index f1f50beb58..7f3cc38712 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index e54a9c1141..ab4f8bddb1 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { TableModule } from "@bitwarden/components"; +import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections"; import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module"; @@ -26,6 +26,7 @@ import { VaultItemsComponent } from "./vault-items.component"; CollectionNameBadgeComponent, GroupBadgeModule, PipesModule, + ScrollLayoutDirective, ], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], exports: [VaultItemsComponent], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 55807ed855..02c845ac94 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -2,7 +2,13 @@ // @ts-strict-ignore import { importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; import { @@ -29,6 +35,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { LayoutComponent } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -48,8 +55,9 @@ export default { title: "Web/Vault/Items", component: VaultItemsComponent, decorators: [ + componentWrapperDecorator((story) => `${story}`), moduleMetadata({ - imports: [VaultItemsModule, RouterModule], + imports: [VaultItemsModule, RouterModule, LayoutComponent], providers: [ { provide: EnvironmentService, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index 66c4267844..f203b7a934 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -55,7 +55,7 @@ > {{ "providerUsersNeedConfirmed" | i18n }} - + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 597acb0d4f..01f1facfc1 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -4,7 +4,7 @@ import { NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CardComponent, SearchModule } from "@bitwarden/components"; +import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components"; import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; @@ -53,6 +53,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr ScrollingModule, VerifyBankAccountComponent, CardComponent, + ScrollLayoutDirective, PaymentComponent, ], declarations: [ diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts index d71e18cd53..e87ee6a118 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts @@ -1,4 +1,3 @@ -import { BasePortalOutlet } from "@angular/cdk/portal"; import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; @@ -33,8 +32,7 @@ export const openCreateClientDialog = ( dialogService: DialogService, dialogConfig: DialogConfig< CreateClientDialogParams, - DialogRef, - BasePortalOutlet + DialogRef >, ) => dialogService.open( diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index e7c5a17c30..5215ad0136 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -1,12 +1,17 @@ import { DIALOG_DATA, DialogModule, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { getAllByRole, userEvent } from "@storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; +import { LayoutComponent } from "../layout"; import { SharedModule } from "../shared"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; import { DialogComponent } from "./dialog/dialog.component"; @@ -19,7 +24,12 @@ interface Animal { } @Component({ - template: ``, + template: ` + + + + + `, }) class StoryDialogComponent { constructor(public dialogService: DialogService) {} @@ -31,6 +41,14 @@ class StoryDialogComponent { }, }); } + + openDrawer() { + this.dialogService.openDrawer(StoryDialogContentComponent, { + data: { + animal: "panda", + }, + }); + } } @Component({ @@ -65,25 +83,37 @@ export default { title: "Component Library/Dialogs/Service", component: StoryDialogComponent, decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ declarations: [StoryDialogContentComponent], imports: [ SharedModule, ButtonModule, + NoopAnimationsModule, DialogModule, IconButtonModule, DialogCloseDirective, DialogComponent, DialogTitleContainerDirective, + RouterTestingModule, + LayoutComponent, ], + providers: [DialogService], + }), + applicationConfig({ providers: [ - DialogService, { provide: I18nService, useFactory: () => { return new I18nMockService({ close: "Close", - loading: "Loading", + search: "Search", + skipToContent: "Skip to content", + submenu: "submenu", + toggleCollapse: "toggle collapse", + toggleSideNavigation: "Toggle side navigation", + yes: "Yes", + no: "No", }); }, }, @@ -100,4 +130,21 @@ export default { type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[0]; + await userEvent.click(button); + }, +}; + +/** Drawers must be a descendant of `bit-layout`. */ +export const Drawer: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[1]; + await userEvent.click(button); + }, +}; diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 83aaaff470..409bf0a5b5 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -1,31 +1,25 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { - DEFAULT_DIALOG_CONFIG, - Dialog, - DialogConfig, - DialogRef, - DIALOG_SCROLL_STRATEGY, + Dialog as CdkDialog, + DialogConfig as CdkDialogConfig, + DialogRef as CdkDialogRefBase, + DIALOG_DATA, + DialogCloseOptions, } from "@angular/cdk/dialog"; -import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay"; -import { - Inject, - Injectable, - Injector, - OnDestroy, - Optional, - SkipSelf, - TemplateRef, -} from "@angular/core"; +import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay"; +import { ComponentPortal, Portal } from "@angular/cdk/portal"; +import { Injectable, Injector, TemplateRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; +import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DrawerService } from "../drawer/drawer.service"; + import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; -import { SimpleDialogOptions, Translation } from "./simple-dialog/types"; +import { SimpleDialogOptions } from "./simple-dialog/types"; /** * The default `BlockScrollStrategy` does not work well with virtual scrolling. @@ -48,61 +42,163 @@ class CustomBlockScrollStrategy implements ScrollStrategy { detach() {} } +export abstract class DialogRef + implements Pick, "close" | "closed" | "disableClose" | "componentInstance"> +{ + abstract readonly isDrawer?: boolean; + + // --- From CdkDialogRef --- + abstract close(result?: R, options?: DialogCloseOptions): void; + abstract readonly closed: Observable; + abstract disableClose: boolean | undefined; + /** + * @deprecated + * Does not work with drawer dialogs. + **/ + abstract componentInstance: C | null; +} + +export type DialogConfig = Pick< + CdkDialogConfig, + "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" +>; + +class DrawerDialogRef implements DialogRef { + readonly isDrawer = true; + + private _closed = new Subject(); + closed = this._closed.asObservable(); + disableClose = false; + + /** The portal containing the drawer */ + portal?: Portal; + + constructor(private drawerService: DrawerService) {} + + close(result?: R, _options?: DialogCloseOptions): void { + if (this.disableClose) { + return; + } + this.drawerService.close(this.portal!); + this._closed.next(result); + this._closed.complete(); + } + + componentInstance: C | null = null; +} + +/** + * DialogRef that delegates functionality to the CDK implementation + **/ +export class CdkDialogRef implements DialogRef { + readonly isDrawer = false; + + /** This is not available until after construction, @see DialogService.open. */ + cdkDialogRefBase!: CdkDialogRefBase; + + // --- Delegated to CdkDialogRefBase --- + + close(result?: R, options?: DialogCloseOptions): void { + this.cdkDialogRefBase.close(result, options); + } + + get closed(): Observable { + return this.cdkDialogRefBase.closed; + } + + get disableClose(): boolean | undefined { + return this.cdkDialogRefBase.disableClose; + } + set disableClose(value: boolean | undefined) { + this.cdkDialogRefBase.disableClose = value; + } + + // Delegate the `componentInstance` property to the CDK DialogRef + get componentInstance(): C | null { + return this.cdkDialogRefBase.componentInstance; + } +} + @Injectable() -export class DialogService extends Dialog implements OnDestroy { - private _destroy$ = new Subject(); +export class DialogService { + private dialog = inject(CdkDialog); + private drawerService = inject(DrawerService); + private injector = inject(Injector); + private router = inject(Router, { optional: true }); + private authService = inject(AuthService, { optional: true }); + private i18nService = inject(I18nService); private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"]; - private defaultScrollStrategy = new CustomBlockScrollStrategy(); + private activeDrawer: DrawerDialogRef | null = null; - constructor( - /** Parent class constructor */ - _overlay: Overlay, - _injector: Injector, - @Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig, - @Optional() @SkipSelf() _parentDialog: Dialog, - _overlayContainer: OverlayContainer, - @Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any, - - /** Not in parent class */ - @Optional() router: Router, - @Optional() authService: AuthService, - - protected i18nService: I18nService, - ) { - super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy); - + constructor() { + /** + * TODO: This logic should exist outside of `libs/components`. + * @see https://bitwarden.atlassian.net/browse/CL-657 + **/ /** Close all open dialogs if the vault locks */ - if (router && authService) { - router.events + if (this.router && this.authService) { + this.router.events .pipe( filter((event) => event instanceof NavigationEnd), - switchMap(() => authService.getAuthStatus()), + switchMap(() => this.authService!.getAuthStatus()), filter((v) => v !== AuthenticationStatus.Unlocked), - takeUntil(this._destroy$), + takeUntilDestroyed(), ) .subscribe(() => this.closeAll()); } } - override ngOnDestroy(): void { - this._destroy$.next(); - this._destroy$.complete(); - super.ngOnDestroy(); - } - - override open( + open( componentOrTemplateRef: ComponentType | TemplateRef, config?: DialogConfig>, ): DialogRef { - config = { + /** + * This is a bit circular in nature: + * We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`, + * but we get the base CDK DialogRef instance *from* `Dialog.open`. + * + * To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase. + * This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance". + **/ + const ref = new CdkDialogRef(); + const injector = this.createInjector({ + data: config?.data, + dialogRef: ref, + }); + + // Merge the custom config with the default config + const _config = { backdropClass: this.backDropClasses, scrollStrategy: this.defaultScrollStrategy, + injector, ...config, }; - return super.open(componentOrTemplateRef, config); + ref.cdkDialogRefBase = this.dialog.open(componentOrTemplateRef, _config); + return ref; + } + + /** Opens a dialog in the side drawer */ + openDrawer( + component: ComponentType, + config?: DialogConfig>, + ): DialogRef { + this.activeDrawer?.close(); + /** + * This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector. + * Similar to `this.open`, we get around this with mutability. + */ + this.activeDrawer = new DrawerDialogRef(this.drawerService); + const portal = new ComponentPortal( + component, + null, + this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }), + ); + this.activeDrawer.portal = portal; + this.drawerService.open(portal); + return this.activeDrawer; } /** @@ -113,8 +209,7 @@ export class DialogService extends Dialog implements OnDestroy { */ async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise { const dialogRef = this.openSimpleDialogRef(simpleDialogOptions); - - return firstValueFrom(dialogRef.closed); + return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v))); } /** @@ -134,20 +229,29 @@ export class DialogService extends Dialog implements OnDestroy { }); } - protected translate(translation: string | Translation, defaultKey?: string): string { - if (translation == null && defaultKey == null) { - return null; - } + /** Close all open dialogs */ + closeAll(): void { + return this.dialog.closeAll(); + } - if (translation == null) { - return this.i18nService.t(defaultKey); - } - - // Translation interface use implies we must localize. - if (typeof translation === "object") { - return this.i18nService.t(translation.key, ...(translation.placeholders ?? [])); - } - - return translation; + /** The injector that is passed to the opened dialog */ + private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector { + return Injector.create({ + providers: [ + { + provide: DIALOG_DATA, + useValue: opts.data, + }, + { + provide: DialogRef, + useValue: opts.dialogRef, + }, + { + provide: CdkDialogRefBase, + useValue: opts.dialogRef, + }, + ], + parent: this.injector, + }); } } diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 01f0598512..eaf7fc2bee 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -1,12 +1,22 @@ +@let isDrawer = dialogRef?.isDrawer;
+ @let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
-

} -

+ @@ -90,72 +151,6 @@ class KitchenSinkDialog { - - - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

- - What did foo say to bar? - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-
-
`, }) export class KitchenSinkMainComponent { @@ -168,7 +163,7 @@ export class KitchenSinkMainComponent { } openDrawer() { - this.drawerOpen.set(true); + this.dialogService.openDrawer(KitchenSinkDialog); } navItems = [ diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index f57a9de4e6..d318e1b5f0 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -14,7 +14,6 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService } from "../../dialog"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; import { positionFixedWrapperDecorator } from "../storybook-decorators"; @@ -39,8 +38,20 @@ export default { KitchenSinkTable, KitchenSinkToggleList, ], + }), + applicationConfig({ providers: [ - DialogService, + provideNoopAnimations(), + importProvidersFrom( + RouterModule.forRoot( + [ + { path: "", redirectTo: "bitwarden", pathMatch: "full" }, + { path: "bitwarden", component: KitchenSinkMainComponent }, + { path: "virtual-scroll", component: DialogVirtualScrollBlockComponent }, + ], + { useHash: true }, + ), + ), { provide: I18nService, useFactory: () => { @@ -58,21 +69,6 @@ export default { }, ], }), - applicationConfig({ - providers: [ - provideNoopAnimations(), - importProvidersFrom( - RouterModule.forRoot( - [ - { path: "", redirectTo: "bitwarden", pathMatch: "full" }, - { path: "bitwarden", component: KitchenSinkMainComponent }, - { path: "virtual-scroll", component: DialogVirtualScrollBlockComponent }, - ], - { useHash: true }, - ), - ), - ], - }), ], } as Meta; diff --git a/libs/components/src/table/table-scroll.component.html b/libs/components/src/table/table-scroll.component.html index 8f2c88ba3a..523912cd7a 100644 --- a/libs/components/src/table/table-scroll.component.html +++ b/libs/components/src/table/table-scroll.component.html @@ -1,5 +1,5 @@ diff --git a/libs/components/src/table/table-scroll.component.ts b/libs/components/src/table/table-scroll.component.ts index 34cd8c5d9c..e198da1aba 100644 --- a/libs/components/src/table/table-scroll.component.ts +++ b/libs/components/src/table/table-scroll.component.ts @@ -21,6 +21,8 @@ import { TrackByFunction, } from "@angular/core"; +import { ScrollLayoutDirective } from "../layout"; + import { RowDirective } from "./row.directive"; import { TableComponent } from "./table.component"; @@ -58,6 +60,7 @@ export class BitRowDef { CdkFixedSizeVirtualScroll, CdkVirtualForOf, RowDirective, + ScrollLayoutDirective, ], }) export class TableScrollComponent diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx index 8d784190ed..59bf5b773a 100644 --- a/libs/components/src/table/table.mdx +++ b/libs/components/src/table/table.mdx @@ -142,7 +142,7 @@ dataSource.filter = (data) => data.orgType === "family"; Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`. It works by converting each entry into a string of it's properties. The provided string is then -compared against the filter value using a simple `indexOf` check. For convienence, you can also just +compared against the filter value using a simple `indexOf` check. For convenience, you can also just pass a string directly. ```ts @@ -153,7 +153,7 @@ dataSource.filter = "search value"; ### Virtual Scrolling -It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount +It's heavily advised to use virtual scrolling if you expect the table to have any significant amount of data. This is done by using the `bit-table-scroll` component instead of the `bit-table` component. This component behaves slightly different from the `bit-table` component. Instead of using the `*ngFor` directive to render the rows, you provide a `bitRowDef` template that will be @@ -178,6 +178,14 @@ height and align vertically. ``` +#### Deprecated approach + +Before `bit-table-scroll` was introduced, virtual scroll in tables was implemented manually via +constructs from Angular CDK. This included wrapping the table with a `cdk-virtual-scroll-viewport` +and targeting with `bit-layout`'s scroll container with the `bitScrollLayout` directive. + +This pattern is deprecated in favor of `bit-table-scroll`. + ## Accessibility - Always include a row or column header with your table; this allows assistive technology to better diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index e8ab24ee8b..d696e6077d 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,6 +1,13 @@ +import { RouterTestingModule } from "@angular/router/testing"; import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { countries } from "../form/countries"; +import { LayoutComponent } from "../layout"; +import { mockLayoutI18n } from "../layout/mocks"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; +import { I18nMockService } from "../utils"; import { TableDataSource } from "./table-data-source"; import { TableModule } from "./table.module"; @@ -8,8 +15,17 @@ import { TableModule } from "./table.module"; export default { title: "Component Library/Table", decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ - imports: [TableModule], + imports: [TableModule, LayoutComponent, RouterTestingModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService(mockLayoutI18n); + }, + }, + ], }), ], argTypes: { @@ -116,18 +132,20 @@ export const Scrollable: Story = { trackBy: (index: number, item: any) => item.id, }, template: ` - - - Id - Name - Other - - - {{ row.id }} - {{ row.name }} - {{ row.other }} - - + + + + Id + Name + Other + + + {{ row.id }} + {{ row.name }} + {{ row.other }} + + + `, }), }; @@ -144,17 +162,19 @@ export const Filterable: Story = { sortFn: (a: any, b: any) => a.id - b.id, }, template: ` - - - - Name - Value - - - {{ row.name }} - {{ row.value }} - - + + + + + Name + Value + + + {{ row.name }} + {{ row.value }} + + + `, }), }; diff --git a/libs/components/src/utils/has-scrolled-from.ts b/libs/components/src/utils/has-scrolled-from.ts new file mode 100644 index 0000000000..44c73465bd --- /dev/null +++ b/libs/components/src/utils/has-scrolled-from.ts @@ -0,0 +1,41 @@ +import { CdkScrollable } from "@angular/cdk/scrolling"; +import { Signal, inject, signal } from "@angular/core"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { map, startWith, switchMap } from "rxjs"; + +export type ScrollState = { + /** `true` when the scrollbar is not at the top-most position */ + top: boolean; + + /** `true` when the scrollbar is not at the bottom-most position */ + bottom: boolean; +}; + +/** + * Check if a `CdkScrollable` instance has been scrolled + * @param scrollable The instance to check, defaults to the one provided by the current injector + * @returns {Signal} + */ +export const hasScrolledFrom = (scrollable?: Signal): Signal => { + const _scrollable = scrollable ?? signal(inject(CdkScrollable)); + const scrollable$ = toObservable(_scrollable); + + const scrollState$ = scrollable$.pipe( + switchMap((_scrollable) => + _scrollable.elementScrolled().pipe( + startWith(null), + map(() => ({ + top: _scrollable.measureScrollOffset("top") > 0, + bottom: _scrollable.measureScrollOffset("bottom") > 0, + })), + ), + ), + ); + + return toSignal(scrollState$, { + initialValue: { + top: false, + bottom: false, + }, + }); +};