1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-02 03:21:19 +00:00

Merge remote-tracking branch 'origin' into auth/pm-18720/change-password-component-non-dialog-v3

This commit is contained in:
Patrick Pimentel
2025-06-23 15:39:09 -04:00
619 changed files with 24974 additions and 5442 deletions

View File

@@ -0,0 +1,20 @@
import { Observable } from "rxjs";
import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
/**
* A simple data service to allow any child components of the AnonLayoutWrapperComponent to override
* page route data and dynamically control the data fed into the AnonLayoutComponent via the AnonLayoutWrapperComponent.
*/
export abstract class AnonLayoutWrapperDataService {
/**
*
* @param data - The data to set on the AnonLayoutWrapperComponent to feed into the AnonLayoutComponent.
*/
abstract setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void;
/**
* Reactively gets the current AnonLayoutWrapperData.
*/
abstract anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData>;
}

View File

@@ -0,0 +1,12 @@
<auth-anon-layout
[title]="pageTitle"
[subtitle]="pageSubtitle"
[icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth"
[titleAreaMaxWidth]="titleAreaMaxWidth"
>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
</auth-anon-layout>

View File

@@ -0,0 +1,174 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Translation } from "../dialog";
import { Icon } from "../icon";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
import { AnonLayoutComponent } from "./anon-layout.component";
export interface AnonLayoutWrapperData {
/**
* The optional title of the page.
* If a string is provided, it will be presented as is (ex: Organization name)
* If a Translation object (supports placeholders) is provided, it will be translated
*/
pageTitle?: string | Translation | null;
/**
* The optional subtitle of the page.
* If a string is provided, it will be presented as is (ex: user's email)
* If a Translation object (supports placeholders) is provided, it will be translated
*/
pageSubtitle?: string | Translation | null;
/**
* The optional icon to display on the page.
*/
pageIcon?: Icon | null;
/**
* Optional flag to either show the optional environment selector (false) or just a readonly hostname (true).
*/
showReadonlyHostname?: boolean;
/**
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
*/
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";
/**
* Optional flag to set the max-width of the title area. Defaults to null if not provided.
*/
titleAreaMaxWidth?: "md";
}
@Component({
templateUrl: "anon-layout-wrapper.component.html",
imports: [AnonLayoutComponent, RouterModule],
})
export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
protected showReadonlyHostname: boolean;
protected maxWidth: "md" | "3xl";
protected titleAreaMaxWidth: "md";
constructor(
private router: Router,
private route: ActivatedRoute,
private i18nService: I18nService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private changeDetectorRef: ChangeDetectorRef,
) {}
ngOnInit(): void {
// Set the initial page data on load
this.setAnonLayoutWrapperDataFromRouteData(this.route.snapshot.firstChild?.data);
// Listen for page changes and update the page data appropriately
this.listenForPageDataChanges();
this.listenForServiceDataChanges();
}
private listenForPageDataChanges() {
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
// reset page data on page changes
tap(() => this.resetPageData()),
switchMap(() => this.route.firstChild?.data || null),
takeUntil(this.destroy$),
)
.subscribe((firstChildRouteData: Data | null) => {
this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData);
});
}
private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) {
if (!firstChildRouteData) {
return;
}
if (firstChildRouteData["pageTitle"] !== undefined) {
this.pageTitle = this.handleStringOrTranslation(firstChildRouteData["pageTitle"]);
}
if (firstChildRouteData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.handleStringOrTranslation(firstChildRouteData["pageSubtitle"]);
}
if (firstChildRouteData["pageIcon"] !== undefined) {
this.pageIcon = firstChildRouteData["pageIcon"];
}
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
this.maxWidth = firstChildRouteData["maxWidth"];
this.titleAreaMaxWidth = firstChildRouteData["titleAreaMaxWidth"];
}
private listenForServiceDataChanges() {
this.anonLayoutWrapperDataService
.anonLayoutWrapperData$()
.pipe(takeUntil(this.destroy$))
.subscribe((data: AnonLayoutWrapperData) => {
this.setAnonLayoutWrapperData(data);
});
}
private setAnonLayoutWrapperData(data: AnonLayoutWrapperData) {
if (!data) {
return;
}
// Null emissions are used to reset the page data as all fields are optional.
if (data.pageTitle !== undefined) {
this.pageTitle =
data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null;
}
if (data.pageSubtitle !== undefined) {
this.pageSubtitle =
data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null;
}
if (data.pageIcon !== undefined) {
this.pageIcon = data.pageIcon !== null ? data.pageIcon : null;
}
if (data.showReadonlyHostname !== undefined) {
this.showReadonlyHostname = data.showReadonlyHostname;
}
// Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
// when setting the page data from a service
this.changeDetectorRef.detectChanges();
}
private handleStringOrTranslation(value: string | Translation): string {
if (typeof value === "string") {
// If it's a string, return it as is
return value;
}
// If it's a Translation object, translate it
return this.i18nService.t(value.key, ...(value.placeholders ?? []));
}
private resetPageData() {
this.pageTitle = null;
this.pageSubtitle = null;
this.pageIcon = null;
this.showReadonlyHostname = null;
this.maxWidth = null;
this.titleAreaMaxWidth = null;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,24 @@
import { Meta, Story, Controls } from "@storybook/addon-docs";
import * as stories from "./anon-layout-wrapper.stories";
<Meta of={stories} />
# Anon Layout Wrapper
## Anon Layout Wrapper Component
The auth owned `AnonLayoutWrapperComponent` orchestrates routing configuration data and feeds it
into the `AnonLayoutComponent`. See the `Anon Layout` storybook for full documentation on how to use
the `AnonLayoutWrapperComponent`.
## Default Example with all 3 outlets used
<Story of={stories.DefaultContentExample} />
## Dynamic Anon Layout Wrapper Content Example
This example demonstrates a child component using the `DefaultAnonLayoutWrapperDataService` to
dynamically set the content of the `AnonLayoutWrapperComponent`.
<Story of={stories.DynamicContentExample} />

View File

@@ -0,0 +1,246 @@
import { importProvidersFrom, Component } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import {
Meta,
StoryObj,
applicationConfig,
componentWrapperDecorator,
moduleMetadata,
} from "@storybook/angular";
import { of } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import {
EnvironmentService,
Environment,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule } from "../button";
import { LockIcon, RegistrationCheckEmailIcon } from "../icon/icons";
import { I18nMockService } from "../utils";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
import { DefaultAnonLayoutWrapperDataService } from "./default-anon-layout-wrapper-data.service";
export default {
title: "Component Library/Anon Layout Wrapper",
component: AnonLayoutWrapperComponent,
} as Meta;
const decorators = (options: {
components: any[];
routes: Routes;
applicationVersion?: string;
clientType?: ClientType;
hostName?: string;
}) => {
return [
componentWrapperDecorator(
/**
* Applying a CSS transform makes a `position: fixed` element act like it is `position: relative`
* https://github.com/storybookjs/storybook/issues/8011#issue-490251969
*/
(story) => {
return /* HTML */ `<div class="tw-scale-100 ">${story}</div>`;
},
({ globals }) => {
/**
* avoid a bug with the way that we render the same component twice in the same iframe and how
* that interacts with the router-outlet
*/
const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"];
return { theme: themeOverride };
},
),
moduleMetadata({
declarations: options.components,
imports: [RouterModule, ButtonModule],
providers: [
{
provide: AnonLayoutWrapperDataService,
useClass: DefaultAnonLayoutWrapperDataService,
},
{
provide: EnvironmentService,
useValue: {
environment$: of({
getHostname: () => options.hostName || "storybook.bitwarden.com",
} as Partial<Environment>),
} as Partial<EnvironmentService>,
},
{
provide: PlatformUtilsService,
useValue: {
getApplicationVersion: () =>
Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"),
getClientType: () => options.clientType || ClientType.Web,
} as Partial<PlatformUtilsService>,
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
setAStrongPassword: "Set a strong password",
appLogoLabel: "app logo label",
finishCreatingYourAccountBySettingAPassword:
"Finish creating your account by setting a password",
});
},
},
],
}),
applicationConfig({
providers: [importProvidersFrom(RouterModule.forRoot(options.routes))],
}),
];
};
type Story = StoryObj<AnonLayoutWrapperComponent>;
// Default Example
@Component({
selector: "bit-default-primary-outlet-example-component",
template: "<p>Primary Outlet Example: <br> your primary component goes here</p>",
standalone: false,
})
export class DefaultPrimaryOutletExampleComponent {}
@Component({
selector: "bit-default-secondary-outlet-example-component",
template: "<p>Secondary Outlet Example: <br> your secondary component goes here</p>",
standalone: false,
})
export class DefaultSecondaryOutletExampleComponent {}
@Component({
selector: "bit-default-env-selector-outlet-example-component",
template: "<p>Env Selector Outlet Example: <br> your env selector component goes here</p>",
standalone: false,
})
export class DefaultEnvSelectorOutletExampleComponent {}
export const DefaultContentExample: Story = {
render: (args) => ({
props: args,
template: "<router-outlet></router-outlet>",
}),
decorators: decorators({
components: [
DefaultPrimaryOutletExampleComponent,
DefaultSecondaryOutletExampleComponent,
DefaultEnvSelectorOutletExampleComponent,
],
routes: [
{
path: "**",
redirectTo: "default-example",
pathMatch: "full",
},
{
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "default-example",
data: {},
children: [
{
path: "",
component: DefaultPrimaryOutletExampleComponent,
},
{
path: "",
component: DefaultSecondaryOutletExampleComponent,
outlet: "secondary",
},
{
path: "",
component: DefaultEnvSelectorOutletExampleComponent,
outlet: "environment-selector",
},
],
},
],
},
],
}),
};
// Dynamic Content Example
const initialData: AnonLayoutWrapperData = {
pageTitle: {
key: "setAStrongPassword",
},
pageSubtitle: {
key: "finishCreatingYourAccountBySettingAPassword",
},
pageIcon: LockIcon,
};
const changedData: AnonLayoutWrapperData = {
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: "user@email.com (non-translated)",
pageIcon: RegistrationCheckEmailIcon,
};
@Component({
selector: "bit-dynamic-content-example-component",
template: `
<button type="button" bitButton buttonType="primary" (click)="toggleData()">Toggle Data</button>
`,
standalone: false,
})
export class DynamicContentExampleComponent {
initialData = true;
constructor(private anonLayoutWrapperDataService: AnonLayoutWrapperDataService) {}
toggleData() {
if (this.initialData) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData);
} else {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData);
}
this.initialData = !this.initialData;
}
}
export const DynamicContentExample: Story = {
render: (args) => ({
props: args,
template: "<router-outlet></router-outlet>",
}),
decorators: decorators({
components: [DynamicContentExampleComponent],
routes: [
{
path: "**",
redirectTo: "dynamic-content-example",
pathMatch: "full",
},
{
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "dynamic-content-example",
data: initialData,
children: [
{
path: "",
component: DynamicContentExampleComponent,
},
],
},
],
},
],
}),
};

View File

@@ -0,0 +1,71 @@
<main
class="tw-flex tw-w-full tw-mx-auto tw-flex-col tw-bg-background-alt tw-px-6 tw-py-4 tw-text-main"
[ngClass]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<a
*ngIf="!hideLogo"
[routerLink]="['/']"
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
<div
class="tw-text-center tw-mb-4 sm:tw-mb-6"
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
>
<div *ngIf="!hideIcon" class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
<bit-icon [icon]="icon"></bit-icon>
</div>
<ng-container *ngIf="title">
<!-- Small screens -->
<h1 bitTypography="h3" class="tw-mt-2 sm:tw-hidden">
{{ title }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h2" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title }}
</h1>
</ng-container>
<div *ngIf="subtitle" class="tw-text-sm sm:tw-text-base tw-w-full tw-max-w-md tw-mx-auto">
{{ subtitle }}
</div>
</div>
<div
class="tw-grow tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="{
'tw-max-w-sm': maxWidth === 'sm',
'tw-max-w-md': maxWidth === 'md',
'tw-max-w-lg': maxWidth === 'lg',
'tw-max-w-xl': maxWidth === 'xl',
'tw-max-w-2xl': maxWidth === '2xl',
'tw-max-w-3xl': maxWidth === '3xl',
}"
>
<div
class="tw-rounded-2xl tw-mb-2 tw-mx-auto tw-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
>
<ng-content></ng-content>
</div>
<ng-content select="[slot=secondary]"></ng-content>
</div>
<footer *ngIf="!hideFooter" class="tw-text-center tw-mt-6">
<div *ngIf="showReadonlyHostname" bitTypography="body2">
{{ "accessing" | i18n }} {{ hostname }}
</div>
<ng-container *ngIf="!showReadonlyHostname">
<ng-content select="[slot=environment-selector]"></ng-content>
</ng-container>
<ng-container *ngIf="!hideYearAndVersion">
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
</ng-container>
</footer>
</main>

View File

@@ -0,0 +1,83 @@
import { CommonModule } from "@angular/common";
import { Component, HostBinding, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { IconModule, Icon } from "../icon";
import { BitwardenLogo, BitwardenShield } from "../icon/icons";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
@Component({
selector: "auth-anon-layout",
templateUrl: "./anon-layout.component.html",
imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule],
})
export class AnonLayoutComponent implements OnInit, OnChanges {
@HostBinding("class")
get classList() {
// AnonLayout should take up full height of parent container for proper footer placement.
return ["tw-h-full"];
}
@Input() title: string;
@Input() subtitle: string;
@Input() icon: Icon;
@Input() showReadonlyHostname?: boolean;
@Input() hideLogo: boolean = false;
@Input() hideFooter: boolean = false;
@Input() hideIcon: boolean = false;
/**
* Max width of the title area content
*
* @default null
*/
@Input() titleAreaMaxWidth?: "md";
/**
* Max width of the layout content
*
* @default 'md'
*/
@Input() maxWidth: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" = "md";
protected logo = BitwardenLogo;
protected year: string;
protected clientType: ClientType;
protected hostname: string;
protected version: string;
protected hideYearAndVersion = false;
constructor(
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,
) {
this.year = new Date().getFullYear().toString();
this.clientType = this.platformUtilsService.getClientType();
this.hideYearAndVersion = this.clientType !== ClientType.Web;
}
async ngOnInit() {
this.maxWidth = this.maxWidth ?? "md";
this.titleAreaMaxWidth = this.titleAreaMaxWidth ?? undefined;
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
this.version = await this.platformUtilsService.getApplicationVersion();
// If there is no icon input, then use the default icon
if (this.icon == null) {
this.icon = BitwardenShield;
}
}
async ngOnChanges(changes: SimpleChanges) {
if (changes.maxWidth) {
this.maxWidth = changes.maxWidth.currentValue ?? "md";
}
}
}

View File

@@ -0,0 +1,168 @@
import { Meta, Story, Controls } from "@storybook/addon-docs";
import * as stories from "./anon-layout.stories";
<Meta of={stories} />
# AnonLayout Component
The AnonLayoutComponent is to be used primarily for unauthenticated pages\*, where we don't know who
the user is.
\*There will be a few exceptions to this&mdash;that is, AnonLayout will also be used for the Unlock
and View Send pages.
---
### Incorrect Usage ❌
The AnonLayoutComponent is **not** to be implemented by every component that uses it in that
component's template directly. For example, if you have a component template called
`example.component.html`, and you want it to use the AnonLayoutComponent, you will **not** be
writing:
```html
<!-- File: example.component.html -->
<auth-anon-layout>
<div>Example component content</div>
</auth-anon-layout>
```
### Correct Usage ✅
Instead the AnonLayoutComponent is implemented solely in the router via routable composition, which
gives us the advantages of nested routes in Angular.
To allow for routable composition, Auth also provides an AnonLayout**Wrapper**Component which embeds
the AnonLayoutComponent.
For clarity:
- AnonLayoutComponent = the base, Auth-owned library component - `<auth-anon-layout>`
- AnonLayout**Wrapper**Component = the wrapper to be used in client routing modules
The AnonLayout**Wrapper**Component embeds the AnonLayoutComponent along with the router outlets:
```html
<!-- File: anon-layout-wrapper.component.html -->
<auth-anon-layout
[title]="pageTitle"
[subtitle]="pageSubtitle"
[icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname"
>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
</auth-anon-layout>
```
To implement, the developer does not need to work with the base AnonLayoutComponent directly. The
devoloper simply uses the AnonLayout**Wrapper**Component in `oss-routing.module.ts` (for Web, for
example) to construct the page via routable composition:
```typescript
// File: oss-routing.module.ts
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
{
path: "",
component: AnonLayoutWrapperComponent, // Wrapper component
children: [
{
path: "sample-route", // replace with your route
children: [
{
path: "",
component: MyPrimaryComponent, // replace with your component
},
{
path: "",
component: MySecondaryComponent, // replace with your component (or remove this secondary outlet object entirely if not needed)
outlet: "secondary",
},
],
data: {
pageTitle: "logIn", // example of a translation key from messages.json
pageSubtitle: "loginWithMasterPassword", // example of a translation key from messages.json
pageIcon: LockIcon, // example of an icon to pass in
} satisfies AnonLayoutWrapperData,
},
],
},
```
(Notice that you can optionally add an `outlet: "secondary"` if you want to project secondary
content below the primary content).
If the AnonLayout**Wrapper**Component is already being used in your client's routing module, then
your work will be as simple as just adding another child route under the `children` array.
<br />
### Data Properties
Routes that use the AnonLayou**tWrapper**Component can take several unique data properties defined
in the `AnonLayoutWrapperData` interface:
- For the `pageTitle` and `pageSubtitle` - pass in a translation key from `messages.json`.
- For the `pageIcon` - import an icon (of type `Icon`) into the router file and use the icon
directly.
- `showReadonlyHostname` - set to `true` if you want to show the hostname in the footer (ex:
"Accessing bitwarden.com")
All of these properties are optional.
```typescript
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
{
// ...
data: {
pageTitle: "logIn",
pageSubtitle: "loginWithMasterPassword",
pageIcon: LockIcon,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
}
```
### Environment Selector
For some routes, you may want to display the environment selector in the footer of the
AnonLayoutComponent. To do so, add the relevant environment selector (Web or Libs version, depending
on your client) as a component with `outlet: "environment-selector"`.
```javascript
// File: oss-routing.module.ts
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
{
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "sample-route",
children: [
{
path: "",
component: MyPrimaryComponent,
},
{
path: "",
component: EnvironmentSelectorComponent, // use Web or Libs component depending on your client
outlet: "environment-selector",
},
],
// ...
},
],
},
```
---
<Story of={stories.WithSecondaryContent} />

View File

@@ -0,0 +1,228 @@
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { BehaviorSubject, of } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule } from "../button";
import { LockIcon } from "../icon/icons";
import { I18nMockService } from "../utils/i18n-mock.service";
import { AnonLayoutComponent } from "./anon-layout.component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getApplicationVersion = () => Promise.resolve("Version 2024.1.1");
getClientType = () => ClientType.Web;
}
export default {
title: "Component Library/Anon Layout",
component: AnonLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, RouterModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
accessing: "Accessing",
appLogoLabel: "app logo label",
});
},
},
{
provide: EnvironmentService,
useValue: {
environment$: new BehaviorSubject({
getHostname() {
return "bitwarden.com";
},
}).asObservable(),
},
},
{
provide: ActivatedRoute,
useValue: { queryParams: of({}) },
},
],
}),
],
args: {
title: "The Page Title",
subtitle: "The subtitle (optional)",
showReadonlyHostname: true,
icon: LockIcon,
hideLogo: false,
},
} as Meta;
type Story = StoryObj<AnonLayoutComponent>;
export const WithPrimaryContent: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};
export const WithSecondaryContent: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
// Notice that slot="secondary" is requred to project any secondary content.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<button bitButton>Perform Action</button>
</div>
</auth-anon-layout>
`,
}),
};
export const WithLongContent: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
</div>
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Expedita, quod est? </p>
<button bitButton>Perform Action</button>
</div>
</auth-anon-layout>
`,
}),
};
export const WithThinPrimaryContent: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div class="text-center">Lorem ipsum</div>
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<button bitButton>Perform Action</button>
</div>
</auth-anon-layout>
`,
}),
};
export const WithCustomIcon: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [icon]="icon" [showReadonlyHostname]="showReadonlyHostname">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};
export const HideIcon: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideIcon]="true" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};
export const HideLogo: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};
export const HideFooter: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};
export const WithTitleAreaMaxWidth: Story = {
render: (args) => ({
props: {
...args,
title: "This is a very long long title to demonstrate titleAreaMaxWidth set to 'md'",
subtitle:
"This is a very long subtitle that demonstrates how the max width container handles longer text content with the titleAreaMaxWidth input set to 'md'. Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?",
},
template: `
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [titleAreaMaxWidth]="'md'">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};

View File

@@ -0,0 +1,16 @@
import { Observable, Subject } from "rxjs";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
export class DefaultAnonLayoutWrapperDataService implements AnonLayoutWrapperDataService {
protected anonLayoutWrapperDataSubject = new Subject<AnonLayoutWrapperData>();
setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void {
this.anonLayoutWrapperDataSubject.next(data);
}
anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData> {
return this.anonLayoutWrapperDataSubject.asObservable();
}
}

View File

@@ -0,0 +1,4 @@
export * from "./anon-layout-wrapper-data.service";
export * from "./anon-layout-wrapper.component";
export * from "./anon-layout.component";
export * from "./default-anon-layout-wrapper-data.service";

View File

@@ -1,20 +1,24 @@
<aside
class="tw-mb-4 tw-box-border tw-rounded-lg tw-border tw-border-l-4 tw-border-solid tw-bg-background tw-ps-3 tw-pe-2 tw-py-2 tw-leading-5 tw-text-main"
class="tw-mb-4 tw-box-border tw-rounded-lg tw-bg-background tw-ps-3 tw-pe-3 tw-py-2 tw-leading-5 tw-text-main"
[ngClass]="calloutClass"
[attr.aria-labelledby]="titleId"
>
@if (title) {
<header
id="{{ titleId }}"
class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-center"
class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-start"
>
@if (icon) {
<i class="bwi" [ngClass]="[icon, headerClass]" aria-hidden="true"></i>
<i
class="bwi !tw-text-main tw-relative tw-top-[3px]"
[ngClass]="[icon]"
aria-hidden="true"
></i>
}
{{ title }}
</header>
}
<div bitTypography="body2">
<div class="tw-ps-6" bitTypography="body2">
<ng-content></ng-content>
</div>
</aside>

View File

@@ -33,8 +33,7 @@ describe("Callout", () => {
component.type = "success";
fixture.detectChanges();
expect(component.title).toBeUndefined();
expect(component.icon).toBe("bwi-check");
expect(component.headerClass).toBe("!tw-text-success");
expect(component.icon).toBe("bwi-check-circle");
});
it("info", () => {
@@ -42,7 +41,6 @@ describe("Callout", () => {
fixture.detectChanges();
expect(component.title).toBeUndefined();
expect(component.icon).toBe("bwi-info-circle");
expect(component.headerClass).toBe("!tw-text-info");
});
it("warning", () => {
@@ -50,7 +48,6 @@ describe("Callout", () => {
fixture.detectChanges();
expect(component.title).toBe("Warning");
expect(component.icon).toBe("bwi-exclamation-triangle");
expect(component.headerClass).toBe("!tw-text-warning");
});
it("danger", () => {
@@ -58,7 +55,6 @@ describe("Callout", () => {
fixture.detectChanges();
expect(component.title).toBe("Error");
expect(component.icon).toBe("bwi-error");
expect(component.headerClass).toBe("!tw-text-danger");
});
});
});

View File

@@ -10,7 +10,7 @@ import { TypographyModule } from "../typography";
export type CalloutTypes = "success" | "info" | "warning" | "danger";
const defaultIcon: Record<CalloutTypes, string> = {
success: "bwi-check",
success: "bwi-check-circle",
info: "bwi-info-circle",
warning: "bwi-exclamation-triangle",
danger: "bwi-error",
@@ -53,26 +53,13 @@ export class CalloutComponent implements OnInit {
get calloutClass() {
switch (this.type) {
case "danger":
return "tw-border-danger-600";
return "tw-bg-danger-100";
case "info":
return "tw-border-info-600";
return "tw-bg-info-100";
case "success":
return "tw-border-success-600";
return "tw-bg-success-100";
case "warning":
return "tw-border-warning-600";
}
}
get headerClass() {
switch (this.type) {
case "danger":
return "!tw-text-danger";
case "info":
return "!tw-text-info";
case "success":
return "!tw-text-success";
case "warning":
return "!tw-text-warning";
return "tw-bg-warning-100";
}
}
}

View File

@@ -39,11 +39,11 @@ export const Info: Story = {
render: (args) => ({
props: args,
template: `
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>Content</bit-callout>
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>The content of the callout</bit-callout>
`,
}),
args: {
title: "Title",
title: "Callout title",
},
};

View File

@@ -28,13 +28,14 @@
#chipSelectButton
>
<span class="tw-inline-flex tw-items-center tw-gap-1.5 tw-truncate">
<i class="bwi !tw-text-[inherit]" [ngClass]="icon"></i>
<i class="bwi !tw-text-[inherit]" [ngClass]="icon" aria-hidden="true"></i>
<span class="tw-truncate">{{ label }}</span>
</span>
@if (!selectedOption) {
<i
class="bwi tw-mt-0.5"
[ngClass]="menuTrigger.isOpen ? 'bwi-angle-up' : 'bwi-angle-down'"
aria-hidden="true"
></i>
}
</button>
@@ -51,7 +52,7 @@
}"
(click)="clear()"
>
<i class="bwi bwi-close tw-text-xs"></i>
<i class="bwi bwi-close tw-text-xs" aria-hidden="true"></i>
</button>
}
</div>
@@ -101,7 +102,7 @@
}
{{ option.label }}
@if (option.children?.length) {
<i slot="end" class="bwi bwi-angle-right"></i>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
}
</button>
}

View File

@@ -1,10 +1,11 @@
import { Component, ElementRef, ViewChild } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "../";
import { ToastService, CopyClickListener, COPY_CLICK_LISTENER } from "../";
import { CopyClickDirective } from "./copy-click.directive";
@@ -34,10 +35,12 @@ describe("CopyClickDirective", () => {
let fixture: ComponentFixture<TestCopyClickComponent>;
const copyToClipboard = jest.fn();
const showToast = jest.fn();
const copyClickListener = mock<CopyClickListener>();
beforeEach(async () => {
copyToClipboard.mockClear();
showToast.mockClear();
copyClickListener.onCopy.mockClear();
await TestBed.configureTestingModule({
imports: [TestCopyClickComponent],
@@ -55,6 +58,7 @@ describe("CopyClickDirective", () => {
},
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
{ provide: ToastService, useValue: { showToast } },
{ provide: COPY_CLICK_LISTENER, useValue: copyClickListener },
],
}).compileComponents();
@@ -92,7 +96,6 @@ describe("CopyClickDirective", () => {
successToastButton.click();
expect(showToast).toHaveBeenCalledWith({
message: "copySuccessful",
title: null,
variant: "success",
});
});
@@ -103,7 +106,6 @@ describe("CopyClickDirective", () => {
infoToastButton.click();
expect(showToast).toHaveBeenCalledWith({
message: "copySuccessful",
title: null,
variant: "info",
});
});
@@ -115,8 +117,15 @@ describe("CopyClickDirective", () => {
expect(showToast).toHaveBeenCalledWith({
message: "valueCopied Content",
title: null,
variant: "success",
});
});
it("should call copyClickListener.onCopy when value is copied", () => {
const successToastButton = fixture.componentInstance.successToastButton.nativeElement;
successToastButton.click();
expect(copyClickListener.onCopy).toHaveBeenCalledWith("success toast shown");
});
});

View File

@@ -1,12 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, HostListener, Input } from "@angular/core";
import { Directive, HostListener, Input, InjectionToken, Inject, Optional } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService, ToastVariant } from "../";
/**
* Listener that can be provided to receive copy events to allow for customized behavior.
*/
export interface CopyClickListener {
onCopy(value: string): void;
}
export const COPY_CLICK_LISTENER = new InjectionToken<CopyClickListener>("CopyClickListener");
@Directive({
selector: "[appCopyClick]",
})
@@ -18,6 +25,7 @@ export class CopyClickDirective {
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
private i18nService: I18nService,
@Optional() @Inject(COPY_CLICK_LISTENER) private copyListener?: CopyClickListener,
) {}
@Input("appCopyClick") valueToCopy = "";
@@ -26,7 +34,7 @@ export class CopyClickDirective {
* When set, the toast displayed will show `<valueLabel> copied`
* instead of the default messaging.
*/
@Input() valueLabel: string;
@Input() valueLabel?: string;
/**
* When set without a value, a success toast will be shown when the value is copied
@@ -54,6 +62,10 @@ export class CopyClickDirective {
@HostListener("click") onClick() {
this.platformUtilsService.copyToClipboard(this.valueToCopy);
if (this.copyListener) {
this.copyListener.onCopy(this.valueToCopy);
}
if (this._showToast) {
const message = this.valueLabel
? this.i18nService.t("valueCopied", this.valueLabel)
@@ -61,7 +73,6 @@ export class CopyClickDirective {
this.toastService.showToast({
variant: this.toastVariant,
title: null,
message,
});
}

View File

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

View File

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

View File

@@ -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>

View File

@@ -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";
}
}
}

View File

@@ -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

View File

@@ -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";

View File

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

View File

@@ -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);
/**

View File

@@ -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 />

View 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);
}
}
}

View File

@@ -0,0 +1,9 @@
import { svgIcon } from "../icon";
export const BitwardenLogo = svgIcon`
<svg viewBox="0 0 290 45" xmlns="http://www.w3.org/2000/svg">
<title>Bitwarden</title>
<path class="tw-fill-marketing-logo" fill-rule="evenodd" clip-rule="evenodd" d="M69.799 10.713c3.325 0 5.911 1.248 7.811 3.848 1.9 2.549 2.85 6.033 2.85 10.453 0 4.576-.95 8.113-2.902 10.61-1.953 2.547-4.592 3.743-7.918 3.743-3.325 0-5.858-1.144-7.758-3.536h-.528l-1.003 2.444a.976.976 0 0 1-.897.572H55.23a.94.94 0 0 1-.95-.936V1.352a.94.94 0 0 1 .95-.936h5.7a.94.94 0 0 1 .95.936v8.009c0 1.144-.105 2.964-.316 5.46h.317c1.741-2.704 4.433-4.108 7.917-4.108Zm-2.428 6.084c-1.847 0-3.273.572-4.17 1.717-.844 1.144-1.32 3.068-1.32 5.668v.832c0 2.964.423 5.097 1.32 6.345.897 1.248 2.322 1.924 4.275 1.924 1.531 0 2.85-.728 3.748-2.184.897-1.404 1.372-3.537 1.372-6.189 0-2.704-.475-4.732-1.372-6.084-.95-1.352-2.27-2.029-3.853-2.029ZM93.022 38.9h-5.7a.94.94 0 0 1-.95-.936V12.221a.94.94 0 0 1 .95-.936h5.7a.94.94 0 0 1 .95.936v25.69c.053.468-.422.988-.95.988Zm20.849-5.564c1.108 0 2.428-.208 4.011-.624a.632.632 0 0 1 .792.624v4.316a.64.64 0 0 1-.37.572c-1.794.728-4.064 1.092-6.597 1.092-3.062 0-5.278-.728-6.651-2.288-1.372-1.508-2.111-3.796-2.111-6.812V16.953h-3.008c-.37 0-.634-.26-.634-.624v-2.444c0-.052.053-.104.053-.156l4.17-2.444 2.058-5.408c.106-.26.317-.417.581-.417h3.8c.369 0 .633.26.633.625v5.252h7.548c.158 0 .317.156.317.312v4.68c0 .364-.264.624-.634.624h-7.178v13.21c0 1.04.317 1.872.897 2.34.528.572 1.373.832 2.323.832Zm35.521 5.564c-.739 0-1.319-.468-1.636-1.144l-5.595-16.797c-.369-1.196-.844-3.016-1.478-5.357h-.158l-.528 1.873-1.108 3.536-5.753 16.797c-.211.676-.845 1.092-1.584 1.092a1.628 1.628 0 0 1-1.583-1.196l-7.02-24.182c-.211-.728.369-1.508 1.214-1.508h.158c.528 0 1.003.364 1.161.884l4.117 14.717c1.003 3.849 1.689 6.657 2.006 8.53h.158c.95-3.85 1.689-6.397 2.164-7.698l5.331-15.393c.211-.624.792-1.04 1.531-1.04.686 0 1.267.416 1.478 1.04l4.961 15.29c1.214 3.9 1.953 6.396 2.217 7.696h.158c.159-1.04.792-3.952 2.006-8.633l3.958-14.509c.159-.52.634-.884 1.162-.884.791 0 1.372.728 1.161 1.508l-6.651 24.182c-.211.728-.844 1.196-1.636 1.196h-.211Zm31.352 0a.962.962 0 0 1-.95-.832l-.475-3.432h-.264c-1.372 1.716-2.745 2.964-4.223 3.692-1.425.728-3.166 1.04-5.119 1.04-2.692 0-4.751-.676-6.228-2.028-1.32-1.196-2.059-2.808-2.164-4.836-.212-2.704.95-5.305 3.166-6.813 2.27-1.456 5.437-2.34 9.712-2.34l5.173-.156v-1.768c0-2.6-.528-4.473-1.637-5.773-1.108-1.3-2.744-1.924-5.067-1.924-2.216 0-4.433.52-6.756 1.612-.58.26-1.266 0-1.53-.572s0-1.248.58-1.456c2.639-1.04 5.226-1.612 7.865-1.612 3.008 0 5.225.78 6.756 2.34 1.478 1.508 2.216 3.953 2.216 7.125v16.901c-.052.312-.527.832-1.055.832Zm-10.926-1.768c2.956 0 5.226-.832 6.862-2.444 1.689-1.612 2.533-3.952 2.533-6.813v-2.6l-4.75.208c-3.853.156-6.545.78-8.234 1.768-1.636.988-2.481 2.6-2.481 4.68 0 1.665.528 3.017 1.531 3.953 1.161.78 2.639 1.248 4.539 1.248Zm31.246-25.638c.792 0 1.584.052 2.481.156a1.176 1.176 0 0 1 1.003 1.352c-.106.624-.739.988-1.372.884-.792-.104-1.584-.208-2.375-.208-2.323 0-4.223.988-5.701 2.912-1.478 1.925-2.217 4.42-2.217 7.333v13.625c0 .676-.527 1.196-1.214 1.196-.686 0-1.213-.52-1.213-1.196V13.105c0-.572.475-1.04 1.055-1.04.581 0 1.056.416 1.056.988l.211 3.848h.158c1.109-1.976 2.323-3.38 3.589-4.16 1.214-.832 2.745-1.248 4.539-1.248Zm18.579 0c1.953 0 3.695.364 5.12 1.04 1.478.676 2.745 1.924 3.853 3.64h.158a122.343 122.343 0 0 1-.158-6.084V1.612c0-.676.528-1.196 1.214-1.196.686 0 1.214.52 1.214 1.196v36.351c0 .468-.37.832-.845.832a.852.852 0 0 1-.844-.78l-.528-3.38h-.211c-2.058 3.068-5.067 4.576-8.92 4.576-3.8 0-6.598-1.144-8.656-3.484-1.953-2.34-3.008-5.668-3.008-10.089 0-4.628.95-8.165 2.955-10.66 2.006-2.237 4.856-3.485 8.656-3.485Zm0 2.236c-3.008 0-5.225 1.04-6.756 3.12-1.478 2.029-2.216 4.993-2.216 8.945 0 7.593 3.008 11.39 9.025 11.39 3.114 0 5.331-.885 6.756-2.653 1.478-1.768 2.164-4.68 2.164-8.737v-.416c0-4.16-.686-7.124-2.164-8.893-1.372-1.872-3.642-2.756-6.809-2.756Zm31.616 25.638c-3.959 0-7.02-1.196-9.289-3.64-2.217-2.392-3.326-5.772-3.326-10.089 0-4.316 1.056-7.748 3.22-10.297 2.164-2.6 5.014-3.9 8.656-3.9 3.167 0 5.753 1.092 7.548 3.276 1.9 2.184 2.797 5.2 2.797 8.997v1.976h-19.634c.052 3.692.897 6.5 2.639 8.477 1.741 1.976 4.169 2.86 7.389 2.86 1.531 0 2.956-.104 4.117-.312.844-.156 1.847-.416 3.061-.832.686-.26 1.425.26 1.425.988 0 .416-.264.832-.686.988-1.267.52-2.481.832-3.589 1.04-1.32.364-2.745.468-4.328.468Zm-.739-25.69c-2.639 0-4.75.832-6.334 2.548-1.583 1.665-2.48 4.16-2.797 7.333h16.89c0-3.068-.686-5.564-2.059-7.28-1.372-1.717-3.272-2.6-5.7-2.6ZM288.733 38.9c-.686 0-1.214-.52-1.214-1.196V21.426c0-2.704-.58-4.68-1.689-5.877-1.214-1.196-2.955-1.872-5.383-1.872-3.273 0-5.648.78-7.126 2.444-1.478 1.613-2.322 4.265-2.322 7.853V37.6c0 .676-.528 1.196-1.214 1.196-.686 0-1.214-.52-1.214-1.196V13.105c0-.624.475-1.092 1.108-1.092.581 0 1.003.416 1.109.936l.316 2.704h.159c1.794-2.808 4.908-4.212 9.448-4.212 6.175 0 9.289 3.276 9.289 9.829V37.6c-.053.727-.633 1.3-1.267 1.3ZM90.225 0c-2.48 0-4.486 1.872-4.486 4.212v.416c0 2.289 2.058 4.213 4.486 4.213s4.486-1.924 4.486-4.213v-.364C94.711 1.872 92.653 0 90.225 0Z" />
<path class="tw-fill-marketing-logo" d="M32.041 24.546V5.95H18.848v33.035c2.336-1.22 4.427-2.547 6.272-3.98 4.614-3.565 6.921-7.051 6.921-10.46Zm5.654-22.314v22.314c0 1.665-.329 3.317-.986 4.953-.658 1.637-1.473 3.09-2.445 4.359-.971 1.268-2.13 2.503-3.475 3.704-1.345 1.2-2.586 2.199-3.725 2.993a46.963 46.963 0 0 1-3.563 2.251c-1.237.707-2.116 1.187-2.636 1.439-.52.251-.938.445-1.252.58-.235.117-.49.175-.765.175s-.53-.058-.766-.174c-.314-.136-.731-.33-1.252-.581-.52-.252-1.398-.732-2.635-1.439a47.003 47.003 0 0 1-3.564-2.251c-1.138-.794-2.38-1.792-3.725-2.993-1.345-1.2-2.503-2.436-3.475-3.704-.972-1.27-1.787-2.722-2.444-4.359C.329 27.863 0 26.211 0 24.546V2.232c0-.504.187-.94.56-1.308A1.823 1.823 0 0 1 1.885.372H35.81c.511 0 .953.184 1.326.552.373.368.56.804.56 1.308Z" />
</svg>
`;

View File

@@ -0,0 +1,7 @@
import { svgIcon } from "../icon";
export const BitwardenShield = svgIcon`
<svg viewBox="0 0 120 132" xmlns="http://www.w3.org/2000/svg">
<path class="tw-fill-marketing-logo" d="M82.2944 69.1899V37.2898H60V93.9624C63.948 91.869 67.4812 89.5927 70.5998 87.1338C78.3962 81.0196 82.2944 75.0383 82.2944 69.1899ZM91.8491 30.9097V69.1899C91.8491 72.0477 91.2934 74.8805 90.182 77.6883C89.0706 80.4962 87.6938 82.9884 86.0516 85.1649C84.4094 87.3415 82.452 89.4598 80.1794 91.5201C77.9068 93.5803 75.8084 95.2916 73.8842 96.654C71.96 98.0164 69.9528 99.304 67.8627 100.517C65.7726 101.73 64.288 102.552 63.4088 102.984C62.5297 103.416 61.8247 103.748 61.2939 103.981C60.8958 104.18 60.4645 104.28 60 104.28C59.5355 104.28 59.1042 104.18 58.7061 103.981C58.1753 103.748 57.4703 103.416 56.5911 102.984C55.712 102.552 54.2273 101.73 52.1372 100.517C50.0471 99.304 48.04 98.0164 46.1158 96.654C44.1916 95.2916 42.0932 93.5803 39.8206 91.5201C37.548 89.4598 35.5906 87.3415 33.9484 85.1649C32.3062 82.9884 30.9294 80.4962 29.818 77.6883C28.7066 74.8805 28.1509 72.0477 28.1509 69.1899V30.9097C28.1509 30.0458 28.4661 29.2981 29.0964 28.6668C29.7267 28.0354 30.4732 27.7197 31.3358 27.7197H88.6642C89.5268 27.7197 90.2732 28.0354 90.9036 28.6668C91.5339 29.2981 91.8491 30.0458 91.8491 30.9097Z" />
</svg>
`;

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,13 @@
export * from "./search";
export * from "./security";
export * from "./bitwarden-logo.icon";
export * from "./bitwarden-shield.icon";
export * from "./extension-bitwarden-logo.icon";
export * from "./lock.icon";
export * from "./generator";
export * from "./no-access";
export * from "./no-results";
export * from "./generator";
export * from "./registration-check-email.icon";
export * from "./search";
export * from "./security";
export * from "./send";
export * from "./settings";
export * from "./vault";

View File

@@ -0,0 +1,17 @@
import { svgIcon } from "../icon";
export const LockIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" fill="none">
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M27.5 48.218a9 9 0 0 1 9-9h47a9 9 0 0 1 9 9v7.5h-2v-7.5a7 7 0 0 0-7-7h-47a7 7 0 0 0-7 7v7.5h-2v-7.5Zm2 30.75v3.75a7 7 0 0 0 7 7h47a7 7 0 0 0 7-7v-3.75h2v3.75a9 9 0 0 1-9 9h-47a9 9 0 0 1-9-9v-3.75h2Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M60 10.718c-11.144 0-20 7.942-20 17.414v11.586h-2V28.132C38 17.317 48.007 8.718 60 8.718c11.991 0 22 8.552 22 19.414v11.586h-2V28.132c0-9.516-8.855-17.414-20-17.414ZM32.028 61.28a1 1 0 0 1 1 1v5.678a1 1 0 1 1-2 0v-5.679a1 1 0 0 1 1-1Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M38.452 65.897a1 1 0 0 1-.647 1.258l-5.472 1.755a1 1 0 1 1-.61-1.904l5.471-1.755a1 1 0 0 1 1.258.646Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M31.442 67.147a1 1 0 0 1 1.396.225l3.356 4.646a1 1 0 0 1-1.622 1.171l-3.355-4.646a1 1 0 0 1 .225-1.396Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M32.607 67.143a1 1 0 0 1 .236 1.394l-3.304 4.646a1 1 0 0 1-1.63-1.159l3.304-4.646a1 1 0 0 1 1.394-.235Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M25.656 65.895a1 1 0 0 1 1.26-.644l5.42 1.755a1 1 0 1 1-.616 1.903l-5.42-1.755a1 1 0 0 1-.644-1.26ZM50.508 61.28a1 1 0 0 1 1 1v5.678a1 1 0 1 1-2 0v-5.679a1 1 0 0 1 1-1Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M56.88 65.895a1 1 0 0 1-.644 1.26l-5.42 1.754a1 1 0 1 1-.616-1.903l5.42-1.755a1 1 0 0 1 1.26.644Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M49.922 67.147a1 1 0 0 1 1.397.225l3.355 4.646a1 1 0 1 1-1.621 1.171l-3.356-4.646a1 1 0 0 1 .225-1.396Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M51.093 67.147a1 1 0 0 1 .226 1.396l-3.356 4.646a1 1 0 0 1-1.621-1.17l3.355-4.647a1 1 0 0 1 1.396-.225Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M44.136 65.895a1 1 0 0 1 1.26-.644l5.42 1.755a1 1 0 1 1-.616 1.903l-5.42-1.755a1 1 0 0 1-.644-1.26ZM62.568 72.603a1 1 0 0 1 1-1h10.84a1 1 0 1 1 0 2h-10.84a1 1 0 0 1-1-1ZM81.049 72.603a1 1 0 0 1 1-1h10.84a1 1 0 1 1 0 2H82.05a1 1 0 0 1-1-1Z" clip-rule="evenodd"/>
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M17.5 67.468c0-7.042 5.708-12.75 12.75-12.75h59.5c7.041 0 12.75 5.708 12.75 12.75s-5.709 12.75-12.75 12.75h-59.5c-7.042 0-12.75-5.708-12.75-12.75Zm12.75-10.75c-5.937 0-10.75 4.813-10.75 10.75s4.813 10.75 10.75 10.75h59.5c5.937 0 10.75-4.813 10.75-10.75s-4.813-10.75-10.75-10.75h-59.5Z" clip-rule="evenodd"/>
</svg>
`;

View File

@@ -0,0 +1,23 @@
import { svgIcon } from "../icon";
export const RegistrationCheckEmailIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" fill="none">
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-width="2.477"
d="M104.552 42.242v12.023M48.415 17.31l6.51-4.858a7.43 7.43 0 0 1 8.984.074l1.172.904M24.844 34.897l-6.857 5.116A7.43 7.43 0 0 0 15 45.97v42.366a7.43 7.43 0 0 0 7.43 7.43h74.691a7.43 7.43 0 0 0 7.431-7.43V64.291" />
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-width="2.477"
d="M25.48 50.5V20.058a2.477 2.477 0 0 1 2.476-2.477h32.091" />
<path class="tw-stroke-art-accent" stroke-linecap="round" stroke-width="2.477"
d="M33.778 27.71h15.674M33.778 37.037h15.217M33.778 46.364h17.728M33.778 55.691h23.206" />
<path class="tw-stroke-art-primary" stroke-width="2.477"
d="M102.645 93.86 74.211 67.614a12.384 12.384 0 0 0-8.4-3.284H52.41c-3.216 0-6.306 1.251-8.616 3.488L16.904 93.86" />
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-width="2.477" d="m71.36 64.73 8.904-5.106" />
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-width="2.477" d="M1.238-1.238h37.47"
transform="matrix(.85856 .5127 .51278 -.85852 15.476 43.85)" />
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.477"
d="M104.722 34.286c0 13.922-11.286 25.207-25.209 25.207-13.922 0-25.208-11.285-25.208-25.207 0-13.92 11.286-25.206 25.208-25.206 13.923 0 25.209 11.285 25.209 25.206Z" />
<path class="tw-stroke-art-accent" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.238"
d="M101.346 34.287c0 11.972-9.82 21.678-21.932 21.678m0-43.355c-12.113 0-21.932 9.705-21.932 21.677" />
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.477"
d="m99.636 49.689 3.853 3.852 14.322 14.32a3.096 3.096 0 0 1 0 4.379l-.303.303a3.097 3.097 0 0 1-4.379 0l-14.322-14.32-3.852-3.853" />
</svg>
`;

View File

@@ -1,5 +1,6 @@
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
export * from "./a11y";
export * from "./anon-layout";
export * from "./async-actions";
export * from "./avatar";
export * from "./badge-list";

View File

@@ -1 +1,2 @@
export * from "./layout.component";
export * from "./scroll-layout.directive";

View File

@@ -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>

View File

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

View 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)
);
}
}

View File

@@ -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>

View File

@@ -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 = [

View File

@@ -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;

View 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>
```

View File

@@ -1,5 +1,5 @@
<cdk-virtual-scroll-viewport
scrollWindow
bitScrollLayout
[itemSize]="rowSize"
[ngStyle]="{ paddingBottom: headerHeight + 'px' }"
>

View File

@@ -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

View File

@@ -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

View File

@@ -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>
`,
}),
};

View File

@@ -29,19 +29,19 @@
--color-info-100: 219 229 246;
--color-info-600: 121 161 233;
--color-info-700: 26 65 172;
--color-info-700: 13 36 123;
--color-warning-100: 255 248 228;
--color-warning-100: 255 244 212;
--color-warning-600: 255 191 0;
--color-warning-700: 172 88 0;
--color-warning-700: 142 64 0;
--color-danger-100: 255 236 239;
--color-danger-600: 203 38 58;
--color-danger-700: 149 27 42;
--color-success-100: 191 236 195;
--color-success-100: 213 243 216;
--color-success-600: 12 128 24;
--color-success-700: 11 111 21;
--color-success-700: 8 81 15;
--color-notification-100: 255 225 247;
--color-notification-600: 192 17 118;
@@ -85,19 +85,19 @@
--color-secondary-600: 143 152 166;
--color-secondary-700: 158 167 181;
--color-success-100: 11 111 21;
--color-success-100: 8 81 15;
--color-success-600: 107 241 120;
--color-success-700: 191 236 195;
--color-success-700: 213 243 216;
--color-danger-100: 149 27 42;
--color-danger-600: 255 78 99;
--color-danger-700: 255 236 239;
--color-warning-100: 172 88 0;
--color-warning-100: 142 64 0;
--color-warning-600: 255 191 0;
--color-warning-700: 255 248 228;
--color-warning-700: 255 244 212;
--color-info-100: 26 65 172;
--color-info-100: 13 36 123;
--color-info-600: 121 161 233;
--color-info-700: 219 229 246;

View 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,
},
});
};