mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[CL-696] un-revert "various drawer improvements" + bug fix (#14887)
* Revert "Revert "[CL-622][CL-562][CL-621][CL-632] various drawer improvements …"
This reverts commit 4b32d1f9dd.
* fix virtual scroll: add .cdk-virtual-scrollable to scroll viewport target
* remove references to main el
* use directives instead of querySelector (#14950)
* remove references to main el
* wip
* banish querySelector to the shadow realm
* revert apps/ files
* Add virtual scrolling docs
Co-authored-by: Vicki League <vleague@bitwarden.com>
* add jsdoc
* run eslint
* fix skip links bug
* Update libs/components/src/layout/layout.component.ts
Co-authored-by: Vicki League <vleague@bitwarden.com>
* update tab handler
* only run on tab
* fix lint
* fix virtual scroll issue due to Angular 19 upgrade (#15193)
thanks Vicki
---------
Co-authored-by: Vicki League <vleague@bitwarden.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { provideAnimations } from "@angular/platform-browser/animations";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { NoopAnimationsModule, provideAnimations } 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 { DialogModule } from "./dialog.module";
|
||||
@@ -16,7 +22,12 @@ interface Animal {
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<button bitButton type="button" (click)="openDialog()">Open Dialog</button>`,
|
||||
template: `
|
||||
<bit-layout>
|
||||
<button class="tw-mr-2" bitButton type="button" (click)="openDialog()">Open Dialog</button>
|
||||
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
|
||||
</bit-layout>
|
||||
`,
|
||||
imports: [ButtonModule],
|
||||
})
|
||||
class StoryDialogComponent {
|
||||
@@ -29,6 +40,14 @@ class StoryDialogComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.dialogService.openDrawer(StoryDialogContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -64,7 +83,21 @@ export default {
|
||||
title: "Component Library/Dialogs/Service",
|
||||
component: StoryDialogComponent,
|
||||
decorators: [
|
||||
positionFixedWrapperDecorator(),
|
||||
moduleMetadata({
|
||||
declarations: [StoryDialogContentComponent],
|
||||
imports: [
|
||||
SharedModule,
|
||||
ButtonModule,
|
||||
NoopAnimationsModule,
|
||||
DialogModule,
|
||||
IconButtonModule,
|
||||
RouterTestingModule,
|
||||
LayoutComponent,
|
||||
],
|
||||
providers: [DialogService],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideAnimations(),
|
||||
DialogService,
|
||||
@@ -73,7 +106,13 @@ export default {
|
||||
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",
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -90,4 +129,21 @@ export default {
|
||||
|
||||
type Story = StoryObj<StoryDialogComponent>;
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<R = unknown, C = unknown>
|
||||
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
|
||||
{
|
||||
abstract readonly isDrawer?: boolean;
|
||||
|
||||
// --- From CdkDialogRef ---
|
||||
abstract close(result?: R, options?: DialogCloseOptions): void;
|
||||
abstract readonly closed: Observable<R | undefined>;
|
||||
abstract disableClose: boolean | undefined;
|
||||
/**
|
||||
* @deprecated
|
||||
* Does not work with drawer dialogs.
|
||||
**/
|
||||
abstract componentInstance: C | null;
|
||||
}
|
||||
|
||||
export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
CdkDialogConfig<D, R>,
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
|
||||
>;
|
||||
|
||||
class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = true;
|
||||
|
||||
private _closed = new Subject<R | undefined>();
|
||||
closed = this._closed.asObservable();
|
||||
disableClose = false;
|
||||
|
||||
/** The portal containing the drawer */
|
||||
portal?: Portal<unknown>;
|
||||
|
||||
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<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = false;
|
||||
|
||||
/** This is not available until after construction, @see DialogService.open. */
|
||||
cdkDialogRefBase!: CdkDialogRefBase<R, C>;
|
||||
|
||||
// --- Delegated to CdkDialogRefBase ---
|
||||
|
||||
close(result?: R, options?: DialogCloseOptions): void {
|
||||
this.cdkDialogRefBase.close(result, options);
|
||||
}
|
||||
|
||||
get closed(): Observable<R | undefined> {
|
||||
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<void>();
|
||||
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<any, any> | 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<R = unknown, D = unknown, C = unknown>(
|
||||
open<R = unknown, D = unknown, C = unknown>(
|
||||
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
|
||||
config?: DialogConfig<D, DialogRef<R, C>>,
|
||||
): DialogRef<R, C> {
|
||||
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<R, C>();
|
||||
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<R, D, C>(componentOrTemplateRef, _config);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** Opens a dialog in the side drawer */
|
||||
openDrawer<R = unknown, D = unknown, C = unknown>(
|
||||
component: ComponentType<C>,
|
||||
config?: DialogConfig<D, DialogRef<R, C>>,
|
||||
): DialogRef<R, C> {
|
||||
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<boolean> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
@let isDrawer = dialogRef?.isDrawer;
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="width"
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl']"
|
||||
@fadeIn
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-p-6 tw-pb-4': isDrawer,
|
||||
'tw-border-secondary-300': showHeaderBorder,
|
||||
'tw-border-transparent': !showHeaderBorder,
|
||||
}"
|
||||
>
|
||||
<h1
|
||||
<h2
|
||||
bitDialogTitleContainer
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
@@ -19,7 +29,7 @@
|
||||
</span>
|
||||
}
|
||||
<ng-content select="[bitDialogTitle]"></ng-content>
|
||||
</h1>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
@@ -32,9 +42,11 @@
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="tw-relative tw-flex tw-flex-col tw-overflow-hidden"
|
||||
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
|
||||
[ngClass]="{
|
||||
'tw-min-h-60': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
}"
|
||||
>
|
||||
@if (loading) {
|
||||
@@ -43,20 +55,28 @@
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
cdkScrollable
|
||||
[ngClass]="{
|
||||
'tw-p-4': !disablePadding,
|
||||
'tw-p-4': !disablePadding && !isDrawer,
|
||||
'tw-px-6 tw-py-4': !disablePadding && isDrawer,
|
||||
'tw-overflow-y-auto': !loading,
|
||||
'tw-invisible tw-overflow-y-hidden': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-4"
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
|
||||
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
|
||||
[ngClass]="{
|
||||
'tw-px-6 tw-py-4': isDrawer,
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-border-secondary-300': showFooterBorder,
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogFooter]"></ng-content>
|
||||
</footer>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
|
||||
import { fadeIn } from "../animations";
|
||||
import { DialogRef } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
|
||||
@@ -16,6 +20,9 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
selector: "bit-dialog",
|
||||
templateUrl: "./dialog.component.html",
|
||||
animations: [fadeIn],
|
||||
host: {
|
||||
"(keydown.esc)": "handleEsc($event)",
|
||||
},
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogTitleContainerDirective,
|
||||
@@ -23,9 +30,15 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
BitIconButtonComponent,
|
||||
DialogCloseDirective,
|
||||
I18nPipe,
|
||||
CdkTrapFocus,
|
||||
CdkScrollable,
|
||||
],
|
||||
})
|
||||
export class DialogComponent {
|
||||
protected dialogRef = inject(DialogRef, { optional: true });
|
||||
private scrollableBody = viewChild.required(CdkScrollable);
|
||||
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
|
||||
|
||||
/** Background color */
|
||||
@Input()
|
||||
background: "default" | "alt" = "default";
|
||||
@@ -63,21 +76,31 @@ export class DialogComponent {
|
||||
|
||||
@HostBinding("class") get classes() {
|
||||
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
|
||||
return ["tw-flex", "tw-flex-col", "tw-w-screen", "tw-p-4", "tw-max-h-[90vh]"].concat(
|
||||
this.width,
|
||||
);
|
||||
return ["tw-flex", "tw-flex-col", "tw-w-screen"]
|
||||
.concat(
|
||||
this.width,
|
||||
this.dialogRef?.isDrawer
|
||||
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
|
||||
: ["tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"],
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
|
||||
handleEsc(event: Event) {
|
||||
this.dialogRef?.close();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
get width() {
|
||||
switch (this.dialogSize) {
|
||||
case "small": {
|
||||
return "tw-max-w-sm";
|
||||
return "md:tw-max-w-sm";
|
||||
}
|
||||
case "large": {
|
||||
return "tw-max-w-3xl";
|
||||
return "md:tw-max-w-3xl";
|
||||
}
|
||||
default: {
|
||||
return "tw-max-w-xl";
|
||||
return "md:tw-max-w-xl";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ For alerts or simple confirmation actions, like speedbumps, use the
|
||||
Dialogs's should be used sparingly as they do call extra attention to themselves and can be
|
||||
interruptive if overused.
|
||||
|
||||
For non-blocking, supplementary content, open dialogs as a
|
||||
[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`).
|
||||
|
||||
## Placement
|
||||
|
||||
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./dialog.module";
|
||||
export * from "./simple-dialog/types";
|
||||
export * from "./dialog.service";
|
||||
export { DialogConfig, DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
export { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { ChangeDetectionStrategy, Component, Signal, inject } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { map } from "rxjs";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { hasScrolledFrom } from "../utils/has-scrolled-from";
|
||||
|
||||
/**
|
||||
* Body container for `bit-drawer`
|
||||
@@ -13,7 +13,7 @@ import { map } from "rxjs";
|
||||
host: {
|
||||
class:
|
||||
"tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
|
||||
"[class.tw-border-t-secondary-300]": "isScrolled()",
|
||||
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
|
||||
},
|
||||
hostDirectives: [
|
||||
{
|
||||
@@ -23,13 +23,5 @@ import { map } from "rxjs";
|
||||
template: ` <ng-content></ng-content> `,
|
||||
})
|
||||
export class DrawerBodyComponent {
|
||||
private scrollable = inject(CdkScrollable);
|
||||
|
||||
/** TODO: share this utility with browser popup header? */
|
||||
protected isScrolled: Signal<boolean> = toSignal(
|
||||
this.scrollable
|
||||
.elementScrolled()
|
||||
.pipe(map(() => this.scrollable.measureScrollOffset("top") > 0)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
protected hasScrolledFrom = hasScrolledFrom();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { DrawerHostDirective } from "./drawer-host.directive";
|
||||
import { DrawerService } from "./drawer.service";
|
||||
|
||||
/**
|
||||
* A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
@@ -24,7 +24,7 @@ import { DrawerHostDirective } from "./drawer-host.directive";
|
||||
templateUrl: "drawer.component.html",
|
||||
})
|
||||
export class DrawerComponent {
|
||||
private drawerHost = inject(DrawerHostDirective);
|
||||
private drawerHost = inject(DrawerService);
|
||||
private portal = viewChild.required(CdkPortal);
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,8 @@ import { DrawerComponent } from "@bitwarden/components";
|
||||
|
||||
# Drawer
|
||||
|
||||
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
|
||||
|
||||
A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
|
||||
<Primary />
|
||||
|
||||
20
libs/components/src/drawer/drawer.service.ts
Normal file
20
libs/components/src/drawer/drawer.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, signal } from "@angular/core";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class DrawerService {
|
||||
private _portal = signal<Portal<unknown> | undefined>(undefined);
|
||||
|
||||
/** The portal to display */
|
||||
portal = this._portal.asReadonly();
|
||||
|
||||
open(portal: Portal<unknown>) {
|
||||
this._portal.set(portal);
|
||||
}
|
||||
|
||||
close(portal: Portal<unknown>) {
|
||||
if (portal === this.portal()) {
|
||||
this._portal.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./layout.component";
|
||||
export * from "./scroll-layout.directive";
|
||||
|
||||
@@ -1,43 +1,54 @@
|
||||
<div
|
||||
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
|
||||
>
|
||||
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
|
||||
<a
|
||||
bitLink
|
||||
class="tw-mx-6 focus-visible:before:!tw-ring-0"
|
||||
[fragment]="mainContentId"
|
||||
[routerLink]="[]"
|
||||
(click)="focusMainContent()"
|
||||
linkType="light"
|
||||
>{{ "skipToContent" | i18n }}</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
@let mainContentId = "main-content";
|
||||
<div class="tw-flex tw-w-full">
|
||||
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
|
||||
<main
|
||||
[id]="mainContentId"
|
||||
tabindex="-1"
|
||||
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<div class="tw-flex tw-w-full" cdkTrapFocus>
|
||||
<div
|
||||
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
|
||||
>
|
||||
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
|
||||
<a
|
||||
#skipLink
|
||||
bitLink
|
||||
class="tw-mx-6 focus-visible:before:!tw-ring-0"
|
||||
[fragment]="mainContentId"
|
||||
[routerLink]="[]"
|
||||
(click)="focusMainContent()"
|
||||
linkType="light"
|
||||
>{{ "skipToContent" | i18n }}</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
|
||||
<main
|
||||
#main
|
||||
[id]="mainContentId"
|
||||
tabindex="-1"
|
||||
bitScrollLayoutHost
|
||||
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div
|
||||
(click)="sideNavService.toggle()"
|
||||
class="tw-pointer-events-auto tw-size-full"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto">
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,61 @@
|
||||
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { Component, ElementRef, inject, viewChild } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
import { LinkModule } from "../link";
|
||||
import { SideNavService } from "../navigation/side-nav.service";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-layout",
|
||||
templateUrl: "layout.component.html",
|
||||
imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
LinkModule,
|
||||
RouterModule,
|
||||
PortalModule,
|
||||
A11yModule,
|
||||
CdkTrapFocus,
|
||||
ScrollLayoutHostDirective,
|
||||
],
|
||||
host: {
|
||||
"(document:keydown.tab)": "handleKeydown($event)",
|
||||
},
|
||||
hostDirectives: [DrawerHostDirective],
|
||||
})
|
||||
export class LayoutComponent {
|
||||
protected mainContentId = "main-content";
|
||||
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected drawerPortal = inject(DrawerHostDirective).portal;
|
||||
protected drawerPortal = inject(DrawerService).portal;
|
||||
|
||||
focusMainContent() {
|
||||
document.getElementById(this.mainContentId)?.focus();
|
||||
private mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
|
||||
protected focusMainContent() {
|
||||
this.mainContent().nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular CDK's focus trap utility is silly and will not respect focus order.
|
||||
* This is a workaround to explicitly focus the skip link when tab is first pressed, if no other item already has focus.
|
||||
*
|
||||
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
|
||||
**/
|
||||
private skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink");
|
||||
handleKeydown(ev: KeyboardEvent) {
|
||||
if (isNothingFocused()) {
|
||||
ev.preventDefault();
|
||||
this.skipLink().nativeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isNothingFocused = (): boolean => {
|
||||
return [document.documentElement, document.body, null].includes(
|
||||
document.activeElement as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
98
libs/components/src/layout/scroll-layout.directive.ts
Normal file
98
libs/components/src/layout/scroll-layout.directive.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { CdkVirtualScrollable, VIRTUAL_SCROLLABLE } from "@angular/cdk/scrolling";
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Injectable,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { filter, fromEvent, Observable, switchMap } from "rxjs";
|
||||
|
||||
/**
|
||||
* A service is needed because we can't inject a directive defined in the template of a parent component. The parent's template is initialized after projected content.
|
||||
**/
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class ScrollLayoutService {
|
||||
scrollableRef = signal<ElementRef<HTMLElement> | null>(null);
|
||||
scrollableRef$ = toObservable(this.scrollableRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the primary scrollable area of a layout component.
|
||||
*
|
||||
* Stores the element reference in a global service so it can be referenced by `ScrollLayoutDirective` even when it isn't a direct child of this directive.
|
||||
**/
|
||||
@Directive({
|
||||
selector: "[bitScrollLayoutHost]",
|
||||
standalone: true,
|
||||
host: {
|
||||
class: "cdk-virtual-scrollable",
|
||||
},
|
||||
})
|
||||
export class ScrollLayoutHostDirective implements OnDestroy {
|
||||
private ref = inject(ElementRef);
|
||||
private service = inject(ScrollLayoutService);
|
||||
|
||||
constructor() {
|
||||
this.service.scrollableRef.set(this.ref as ElementRef<HTMLElement>);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.service.scrollableRef.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scroll viewport to the element marked with `ScrollLayoutHostDirective`.
|
||||
*
|
||||
* `ScrollLayoutHostDirective` is set on the primary scrollable area of a layout component (`bit-layout`, `popup-page`, etc).
|
||||
*
|
||||
* @see "Virtual Scrolling" in Storybook.
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitScrollLayout]",
|
||||
standalone: true,
|
||||
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }],
|
||||
})
|
||||
export class ScrollLayoutDirective extends CdkVirtualScrollable implements OnInit {
|
||||
private service = inject(ScrollLayoutService);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
effect(() => {
|
||||
const scrollableRef = this.service.scrollableRef();
|
||||
if (!scrollableRef) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("ScrollLayoutDirective can't find scroll host");
|
||||
return;
|
||||
}
|
||||
|
||||
this.elementRef = scrollableRef;
|
||||
});
|
||||
}
|
||||
|
||||
override elementScrolled(): Observable<Event> {
|
||||
return this.service.scrollableRef$.pipe(
|
||||
filter((ref) => ref !== null),
|
||||
switchMap((ref) => fromEvent(ref.nativeElement, "scroll")),
|
||||
);
|
||||
}
|
||||
|
||||
override getElementRef(): ElementRef<HTMLElement> {
|
||||
return this.service.scrollableRef()!;
|
||||
}
|
||||
|
||||
override measureBoundingClientRectWithScrollOffset(
|
||||
from: "left" | "top" | "right" | "bottom",
|
||||
): number {
|
||||
return (
|
||||
this.service.scrollableRef()!.nativeElement.getBoundingClientRect()[from] -
|
||||
this.measureScrollOffset(from)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,23 @@ import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { DialogModule, DialogService } from "../../../dialog";
|
||||
import { IconButtonModule } from "../../../icon-button";
|
||||
import { ScrollLayoutDirective } from "../../../layout";
|
||||
import { SectionComponent } from "../../../section";
|
||||
import { TableDataSource, TableModule } from "../../../table";
|
||||
|
||||
@Component({
|
||||
selector: "dialog-virtual-scroll-block",
|
||||
imports: [DialogModule, IconButtonModule, SectionComponent, TableModule, ScrollingModule],
|
||||
standalone: true,
|
||||
imports: [
|
||||
DialogModule,
|
||||
IconButtonModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
ScrollingModule,
|
||||
ScrollLayoutDirective,
|
||||
],
|
||||
template: /*html*/ `<bit-section>
|
||||
<cdk-virtual-scroll-viewport scrollWindow itemSize="47">
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout itemSize="63.5">
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
|
||||
@@ -11,8 +11,69 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
|
||||
@Component({
|
||||
imports: [KitchenSinkSharedModule],
|
||||
template: `
|
||||
<bit-dialog title="Dialog Title" dialogSize="large">
|
||||
<span bitDialogContent> Dialog body text goes here. </span>
|
||||
<bit-dialog title="Dialog Title" dialogSize="small">
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>What did foo say to bar?</bit-label>
|
||||
<input bitInput value="Baz" />
|
||||
</bit-form-field>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">OK</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
|
||||
@@ -88,72 +149,6 @@ class KitchenSinkDialog {
|
||||
</bit-section>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
|
||||
<bit-drawer [(open)]="drawerOpen">
|
||||
<bit-drawer-header title="Foo ipsum"></bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>What did foo say to bar?</bit-label>
|
||||
<input bitInput value="Baz" />
|
||||
</bit-form-field>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
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.
|
||||
</p>
|
||||
</bit-drawer-body>
|
||||
</bit-drawer>
|
||||
`,
|
||||
})
|
||||
export class KitchenSinkMainComponent {
|
||||
@@ -166,7 +161,7 @@ export class KitchenSinkMainComponent {
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.drawerOpen.set(true);
|
||||
this.dialogService.openDrawer(KitchenSinkDialog);
|
||||
}
|
||||
|
||||
navItems = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
60
libs/components/src/stories/virtual-scrolling.mdx
Normal file
60
libs/components/src/stories/virtual-scrolling.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Meta } from "@storybook/addon-docs";
|
||||
|
||||
<Meta title="Documentation/Virtual Scrolling" />
|
||||
|
||||
# Virtual Scrolling
|
||||
|
||||
Virtual scrolling is a technique that improves the rendering performance of very large lists by only
|
||||
rendering whatever is currently visible within the viewport. We build on top of
|
||||
[Angular CDK's `ScrollingModule`](https://material.angular.dev/cdk/scrolling/overview).
|
||||
|
||||
## Scrolling the entire layout
|
||||
|
||||
Often, a design calls for the scroll container to envelop the entire page. To support this,
|
||||
AngularCDK provides a `scrollWindow` directive that sets the window to be virtual scroll viewport.
|
||||
We export a similar directive, `bitScrollLayout`, that integrates with `bit-layout` and `popup-page`
|
||||
and should be used instead of `scrollWindow`.
|
||||
|
||||
```html
|
||||
<!-- Descendant of bit-layout -->
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout>
|
||||
<!-- virtual scroll implementation here -->
|
||||
</cdk-virtual-scroll-viewport>
|
||||
```
|
||||
|
||||
### Known footgun
|
||||
|
||||
Due to the initialization order of Angular components and their templates, `bitScrollLayout` will
|
||||
error if it is used _in the same template_ as the layout component:
|
||||
|
||||
```html
|
||||
<bit-layout>
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout>
|
||||
<!-- virtual scroll implementation here -->
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</bit-layout>
|
||||
```
|
||||
|
||||
In this particular composition, the child content gets constructed before the template of
|
||||
`bit-layout` and thus has no scroll container to reference. Workarounds include:
|
||||
|
||||
1. Wrap the child in another component. (This tends to happen by default when the layout is
|
||||
integrated with a `router-outlet`.)
|
||||
|
||||
```html
|
||||
<bit-layout>
|
||||
<component-that-contains-bitScrollLayout></component-that-contains-bitScrollLayout>
|
||||
</bit-layout>
|
||||
```
|
||||
|
||||
2. Use a `defer` block.
|
||||
|
||||
```html
|
||||
<bit-layout>
|
||||
@defer (on immediate) {
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout>
|
||||
<!-- virtual scroll implementation here -->
|
||||
</div>
|
||||
}
|
||||
</bit-layout>
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
<cdk-virtual-scroll-viewport
|
||||
scrollWindow
|
||||
bitScrollLayout
|
||||
[itemSize]="rowSize"
|
||||
[ngStyle]="{ paddingBottom: headerHeight + 'px' }"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CdkVirtualScrollViewport,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
CdkVirtualForOf,
|
||||
CdkVirtualScrollableWindow,
|
||||
} from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
@@ -21,6 +20,8 @@ import {
|
||||
TrackByFunction,
|
||||
} from "@angular/core";
|
||||
|
||||
import { ScrollLayoutDirective } from "../layout";
|
||||
|
||||
import { RowDirective } from "./row.directive";
|
||||
import { TableComponent } from "./table.component";
|
||||
|
||||
@@ -52,10 +53,10 @@ export class BitRowDef {
|
||||
imports: [
|
||||
CommonModule,
|
||||
CdkVirtualScrollViewport,
|
||||
CdkVirtualScrollableWindow,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
CdkVirtualForOf,
|
||||
RowDirective,
|
||||
ScrollLayoutDirective,
|
||||
],
|
||||
})
|
||||
export class TableScrollComponent
|
||||
|
||||
@@ -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.
|
||||
</bit-table-scroll>
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -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: `
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="id" default>Id</th>
|
||||
<th bitCell bitSortable="name">Name</th>
|
||||
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.id }}</td>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.other }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
<bit-layout>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="id" default>Id</th>
|
||||
<th bitCell bitSortable="name">Name</th>
|
||||
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.id }}</td>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.other }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -144,17 +162,19 @@ export const Filterable: Story = {
|
||||
sortFn: (a: any, b: any) => a.id - b.id,
|
||||
},
|
||||
template: `
|
||||
<input type="search" placeholder="Search" (input)="dataSource.filter = $event.target.value" />
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="name" default>Name</th>
|
||||
<th bitCell bitSortable="value" width="120px">Value</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.value }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
<bit-layout>
|
||||
<input type="search" placeholder="Search" (input)="dataSource.filter = $event.target.value" />
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="name" default>Name</th>
|
||||
<th bitCell bitSortable="value" width="120px">Value</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.value }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
41
libs/components/src/utils/has-scrolled-from.ts
Normal file
41
libs/components/src/utils/has-scrolled-from.ts
Normal file
@@ -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<ScrollState>}
|
||||
*/
|
||||
export const hasScrolledFrom = (scrollable?: Signal<CdkScrollable>): Signal<ScrollState> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user