1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

resolve merge conflicts

This commit is contained in:
William Martin
2025-06-30 12:58:11 -04:00
1495 changed files with 52711 additions and 21255 deletions

View File

@@ -1,16 +1,15 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
const sharedConfig = require("../shared/jest.config.angular");
/** @type {import('jest').Config} */
module.exports = {
...sharedConfig,
displayName: "libs/components tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",
prefix: "<rootDir>/../../",
}),
};

View File

@@ -4,7 +4,6 @@ import { FocusableElement } from "../shared/focusable-element";
@Directive({
selector: "[bitA11yCell]",
standalone: true,
providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }],
})
export class A11yCellDirective implements FocusableElement {

View File

@@ -26,7 +26,6 @@ import { A11yRowDirective } from "./a11y-row.directive";
*/
@Directive({
selector: "[bitA11yGrid]",
standalone: true,
})
export class A11yGridDirective {
private viewPort = inject(CdkVirtualScrollViewport, { optional: true });

View File

@@ -11,7 +11,6 @@ import { A11yCellDirective } from "./a11y-cell.directive";
@Directive({
selector: "[bitA11yRow]",
standalone: true,
})
export class A11yRowDirective {
@HostBinding("attr.role")

View File

@@ -4,7 +4,6 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
@Directive({
selector: "[appA11yTitle]",
standalone: true,
})
export class A11yTitleDirective implements OnInit {
@Input() set appA11yTitle(title: string) {

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,13 @@
<auth-anon-layout
[title]="pageTitle"
[subtitle]="pageSubtitle"
[icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth"
[titleAreaMaxWidth]="titleAreaMaxWidth"
[hideCardWrapper]="hideCardWrapper"
>
<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,185 @@
// 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?: "md" | "3xl";
/**
* Optional flag to set the max-width of the title area. Defaults to null if not provided.
*/
titleAreaMaxWidth?: "md";
/**
* Hide the card that wraps the default content. Defaults to false.
*/
hideCardWrapper?: boolean;
}
@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";
protected hideCardWrapper: boolean;
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"];
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
}
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;
}
if (data.hideCardWrapper !== undefined) {
this.hideCardWrapper = data.hideCardWrapper;
}
// 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;
this.hideCardWrapper = 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,247 @@
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",
enterpriseSingleSignOn: "Enterprise Single Sign-On",
});
},
},
],
}),
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,72 @@
<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">{{ 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-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }"
>
@if (hideCardWrapper) {
<div class="tw-mb-6 sm:tw-mb-10">
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</div>
} @else {
<div
class="tw-rounded-2xl tw-mb-6 sm:tw-mb-10 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-container *ngTemplateOutlet="defaultContent"></ng-container>
</div>
}
<ng-content select="[slot=secondary]"></ng-content>
</div>
<footer *ngIf="!hideFooter" class="tw-text-center tw-mt-4 sm: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>
<ng-template #defaultContent>
<ng-content></ng-content>
</ng-template>

View File

@@ -0,0 +1,86 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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;
@Input() hideCardWrapper: boolean = false;
/**
* Max width of the title area content
*
* @default null
*/
@Input() titleAreaMaxWidth?: "md";
/**
* Max width of the layout content
*
* @default 'md'
*/
@Input() maxWidth: "md" | "3xl" = "md";
protected logo = BitwardenLogo;
protected year = "2024";
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 ?? null;
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,250 @@
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,
hideCardWrapper: 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="tw-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="tw-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="tw-text-center">Lorem ipsum</div>
<div slot="secondary" class="tw-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 HideCardWrapper: Story = {
render: (args) => ({
props: {
...args,
hideCardWrapper: true,
},
template: `
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [hideCardWrapper]="hideCardWrapper">
<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="tw-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 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

@@ -15,7 +15,6 @@ import { FunctionReturningAwaitable, functionToObservable } from "../utils/funct
*/
@Directive({
selector: "[bitAction]",
standalone: true,
})
export class BitActionDirective implements OnDestroy {
private destroy$ = new Subject<void>();

View File

@@ -14,7 +14,6 @@ import { FunctionReturningAwaitable, functionToObservable } from "../utils/funct
*/
@Directive({
selector: "[formGroup][bitSubmit]",
standalone: true,
})
export class BitSubmitDirective implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();

View File

@@ -25,7 +25,6 @@ import { BitSubmitDirective } from "./bit-submit.directive";
*/
@Directive({
selector: "button[bitFormButton]",
standalone: true,
})
export class BitFormButtonDirective implements OnDestroy {
private destroy$ = new Subject<void>();

View File

@@ -30,11 +30,11 @@ const template = `
<button type="button" bitSuffix bitIconButton="bwi-refresh" bitFormButton [bitAction]="refresh"></button>
</bit-form-field>
<button class="tw-mr-2" type="submit" buttonType="primary" bitButton bitFormButton>Submit</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitButton bitFormButton>Cancel</button>
<button class="tw-mr-2" type="button" buttonType="danger" bitButton bitFormButton [bitAction]="delete">Delete</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitButton bitFormButton [disabled]="true">Disabled</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
<button class="tw-me-2" type="submit" buttonType="primary" bitButton bitFormButton>Submit</button>
<button class="tw-me-2" type="button" buttonType="secondary" bitButton bitFormButton>Cancel</button>
<button class="tw-me-2" type="button" buttonType="danger" bitButton bitFormButton [bitAction]="delete">Delete</button>
<button class="tw-me-2" type="button" buttonType="secondary" bitButton bitFormButton [disabled]="true">Disabled</button>
<button class="tw-me-2" type="button" buttonType="secondary" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
</form>`;
@Component({

View File

@@ -12,7 +12,7 @@ import { IconButtonModule } from "../icon-button";
import { BitActionDirective } from "./bit-action.directive";
const template = /*html*/ `
<button bitButton buttonType="primary" [bitAction]="action" class="tw-mr-2">
<button bitButton buttonType="primary" [bitAction]="action" class="tw-me-2">
Perform action {{ statusEmoji }}
</button>
<button bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;

View File

@@ -16,12 +16,17 @@ const SizeClasses: Record<SizeTypes, string[]> = {
xsmall: ["tw-h-6", "tw-w-6"],
};
/**
* Avatars display a unique color that helps a user visually recognize their logged in account.
* A variance in color across the avatar component is important as it is used in Account Switching as a
* visual indicator to recognize which of a personal or work account a user is logged into.
*/
@Component({
selector: "bit-avatar",
template: `@if (src) {
<img [src]="src" title="{{ title || text }}" [ngClass]="classList" />
}`,
standalone: true,
imports: [NgClass],
})
export class AvatarComponent implements OnChanges {

View File

@@ -1,15 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Description, Meta, Canvas, Primary, Controls, Title } from "@storybook/addon-docs";
import * as stories from "./avatar.stories";
<Meta of={stories} />
# Avatar
```ts
import { AvatarModule } from "@bitwarden/components";
```
Avatars display a unique color that helps a user visually recognize their logged in account.
A variance in color across the avatar component is important as it is used in Account Switching as a
visual indicator to recognize which of a personal or work account a user is logged into.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AvatarComponent } from "./avatar.component";
export default {
@@ -21,42 +23,56 @@ export default {
type Story = StoryObj<AvatarComponent>;
export const Default: Story = {
render: (args) => {
return {
props: args,
template: `
<bit-avatar ${formatArgsForCodeSnippet<AvatarComponent>(args)}></bit-avatar>
`,
};
},
args: {
color: "#175ddc",
},
};
export const Large: Story = {
...Default,
args: {
size: "large",
},
};
export const Small: Story = {
...Default,
args: {
size: "small",
},
};
export const LightBackground: Story = {
...Default,
args: {
color: "#d2ffcf",
},
};
export const Border: Story = {
...Default,
args: {
border: true,
},
};
export const ColorByID: Story = {
...Default,
args: {
id: "236478",
},
};
export const ColorByText: Story = {
...Default,
args: {
text: "Jason Doe",
},

View File

@@ -10,7 +10,6 @@ import { BadgeModule, BadgeVariant } from "../badge";
@Component({
selector: "bit-badge-list",
templateUrl: "badge-list.component.html",
standalone: true,
imports: [BadgeModule, I18nPipe],
})
export class BadgeListComponent implements OnChanges {

View File

@@ -2,6 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { BadgeModule } from "../badge";
import { SharedModule } from "../shared";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -44,7 +45,7 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<bit-badge-list [variant]="variant" [maxItems]="maxItems" [items]="items" [truncate]="truncate"></bit-badge-list>
<bit-badge-list ${formatArgsForCodeSnippet<BadgeListComponent>(args)}></bit-badge-list>
`,
}),

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
@@ -45,13 +43,23 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
"hover:!tw-text-contrast",
],
};
/**
* Badges are primarily used as labels, counters, and small buttons.
* Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted.
* The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
* > `NOTE:` The Focus and Hover states only apply to badges used for interactive events.
*
* > `NOTE:` The `disabled` state only applies to buttons.
*
*/
@Component({
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
providers: [{ provide: FocusableElement, useExisting: BadgeComponent }],
imports: [CommonModule],
templateUrl: "badge.component.html",
standalone: true,
})
export class BadgeComponent implements FocusableElement {
@HostBinding("class") get classList() {
@@ -89,7 +97,7 @@ export class BadgeComponent implements FocusableElement {
if (this.title !== undefined) {
return this.title;
}
return this.truncate ? this.el.nativeElement.textContent.trim() : null;
return this.truncate ? this?.el?.nativeElement?.textContent?.trim() : null;
}
/**

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./badge.stories";
@@ -8,25 +8,15 @@ import * as stories from "./badge.stories";
import { BadgeModule } from "@bitwarden/components";
```
# Badge
Badges are primarily used as labels, counters, and small buttons.
Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the
component configurations may be reviewed and adjusted.
The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
for interactive events. The Focus and Hover states only apply to badges used for interactive events.
The `disabled` state only applies to buttons.
The story below uses the `<button>` element to demonstrate all the possible states.
<Title />
<Description />
<Primary />
<Controls />
## Styles
### Primary
### Default / Primary
The primary badge is used to indicate an active status (example: device management page) or provide
additional information (example: type of emergency access granted).

View File

@@ -1,6 +1,8 @@
import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { BadgeComponent } from "./badge.component";
export default {
@@ -12,7 +14,6 @@ export default {
}),
],
args: {
variant: "primary",
truncate: false,
},
parameters: {
@@ -25,45 +26,11 @@ export default {
type Story = StoryObj<BadgeComponent>;
export const Variants: Story = {
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<span class="tw-text-main tw-mx-1">Default</span>
<button class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Hover</span>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Focus Visible</span>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Disabled</span>
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<span bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge containing lengthy text</span>
`,
}),
};
@@ -72,11 +39,17 @@ export const Primary: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<span class="tw-text-main">Span </span><span bitBadge [variant]="variant" [truncate]="truncate">Badge containing lengthy text</span>
<br /><br />
<span class="tw-text-main">Link </span><a href="#" bitBadge [variant]="variant" [truncate]="truncate">Badge</a>
<br /><br />
<span class="tw-text-main">Button </span><button bitBadge [variant]="variant" [truncate]="truncate">Badge</button>
<div class="tw-flex tw-flex-col tw-gap-4">
<div class="tw-flex tw-items-center tw-gap-2">
<span class="tw-text-main">span</span><span bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge containing lengthy text</span>
</div>
<div class="tw-flex tw-items-center tw-gap-2">
<span class="tw-text-main">link </span><a href="#" bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</a>
</div>
<div class="tw-flex tw-items-center tw-gap-2">
<span class="tw-text-main">button </span><button bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</button>
</div>
</div>
`,
}),
};
@@ -129,3 +102,46 @@ export const Truncated: Story = {
truncate: true,
},
};
export const VariantsAndInteractionStates: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<span class="tw-text-main tw-mx-1">Default</span>
<button class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Hover</span>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Focus Visible</span>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Disabled</span>
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
`,
}),
};

View File

@@ -1,5 +1,5 @@
<div
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-pl-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
[ngClass]="bannerClass"
[attr.role]="useAlertRole ? 'status' : null"
[attr.aria-live]="useAlertRole ? 'polite' : null"

View File

@@ -7,23 +7,31 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { IconButtonModule } from "../icon-button";
type BannerTypes = "premium" | "info" | "warning" | "danger";
type BannerType = "premium" | "info" | "warning" | "danger";
const defaultIcon: Record<BannerTypes, string> = {
const defaultIcon: Record<BannerType, string> = {
premium: "bwi-star",
info: "bwi-info-circle",
warning: "bwi-exclamation-triangle",
danger: "bwi-error",
};
/**
* Banners are used for important communication with the user that needs to be seen right away, but has
* little effect on the experience. Banners appear at the top of the user's screen on page load and
* persist across all pages a user navigates to.
* - They should always be dismissible and never use a timeout. If a user dismisses a banner, it should not reappear during that same active session.
* - Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their effectiveness may decrease if too many are used.
* - Avoid stacking multiple banners.
* - Banners can contain a button or anchor that uses the `bitLink` directive with `linkType="secondary"`.
*/
@Component({
selector: "bit-banner",
templateUrl: "./banner.component.html",
standalone: true,
imports: [CommonModule, IconButtonModule, I18nPipe],
})
export class BannerComponent implements OnInit {
@Input("bannerType") bannerType: BannerTypes = "info";
@Input("bannerType") bannerType: BannerType = "info";
@Input() icon: string;
@Input() useAlertRole = true;
@Input() showClose = true;

View File

@@ -1,25 +1,16 @@
import { Meta, Controls, Canvas, Primary } from "@storybook/addon-docs";
import { Canvas, Controls, Description, Meta, Primary, Title } from "@storybook/addon-docs";
import * as stories from "./banner.stories";
<Meta of={stories} />
# Banner
Banners are used for important communication with the user that needs to be seen right away, but has
little effect on the experience. Banners appear at the top of the user's screen on page load and
persist across all pages a user navigates to.
- They should always be dismissible and never use a timeout. If a user dismisses a banner, it should
not reappear during that same active session.
- Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their
effectiveness may decrease if too many are used.
- Avoid stacking multiple banners.
- Banners can contain a button or anchor that uses the `bitLink` directive with
`linkType="secondary"`.
```ts
import { BannerModule } from "@bitwarden/components";
```
<Title />
<Description />
<Primary />
<Controls />
## Types
@@ -56,5 +47,5 @@ Rarely used, but may be used to alert users over critical messages or very outda
## Accessibility
Banners sets the `role="status"` and `aria-live="polite"` attributes to ensure screen readers
announce the content prior to the test of the page. This behaviour can be disabled by setting
announce the content prior to the test of the page. This behavior can be disabled by setting
`[useAlertRole]="false"`.

View File

@@ -2,6 +2,7 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { IconButtonModule } from "../icon-button";
import { LinkModule } from "../link";
import { SharedModule } from "../shared/shared.module";
@@ -44,48 +45,50 @@ export default {
type Story = StoryObj<BannerComponent>;
export const Base: Story = {
render: (args) => {
return {
props: args,
template: `
<bit-banner ${formatArgsForCodeSnippet<BannerComponent>(args)}>
Content Really Long Text Lorem Ipsum Ipsum Ipsum
<button bitLink linkType="secondary">Button</button>
</bit-banner>
`,
};
},
};
export const Premium: Story = {
...Base,
args: {
bannerType: "premium",
},
render: (args) => ({
props: args,
template: `
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)" [showClose]=showClose>
Content Really Long Text Lorem Ipsum Ipsum Ipsum
<button bitLink linkType="secondary">Button</button>
</bit-banner>
`,
}),
};
Premium.args = {
bannerType: "premium",
};
export const Info: Story = {
...Premium,
...Base,
args: {
bannerType: "info",
},
};
export const Warning: Story = {
...Premium,
...Base,
args: {
bannerType: "warning",
},
};
export const Danger: Story = {
...Premium,
...Base,
args: {
bannerType: "danger",
},
};
export const HideClose: Story = {
...Premium,
...Base,
args: {
showClose: false,
},

View File

@@ -1,6 +1,6 @@
<ng-template>
@if (icon) {
<i class="bwi {{ icon }} !tw-mr-2" aria-hidden="true"></i>
<i class="bwi {{ icon }} !tw-me-2" aria-hidden="true"></i>
}
<ng-content></ng-content>
</ng-template>

View File

@@ -7,7 +7,6 @@ import { QueryParamsHandling } from "@angular/router";
@Component({
selector: "bit-breadcrumb",
templateUrl: "./breadcrumb.component.html",
standalone: true,
})
export class BreadcrumbComponent {
@Input()

View File

@@ -8,10 +8,14 @@ import { MenuModule } from "../menu";
import { BreadcrumbComponent } from "./breadcrumb.component";
/**
* Breadcrumbs are used to help users understand where they are in a products navigation. Typically
* Bitwarden uses this component to indicate the user's current location in a set of data organized in
* containers (Collections, Folders, or Projects).
*/
@Component({
selector: "bit-breadcrumbs",
templateUrl: "./breadcrumbs.component.html",
standalone: true,
imports: [CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule],
})
export class BreadcrumbsComponent {

View File

@@ -1,14 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./breadcrumbs.stories";
<Meta of={stories} />
# Breadcrumbs
```ts
import { BreadcrumbsModule } from "@bitwarden/components";
```
Breadcrumbs are used to help users understand where they are in a products navigation. Typically
Bitwarden uses this component to indicate the user's current location in a set of data organized in
containers (Collections, Folders, or Projects).
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ButtonModule } from "./index";
@@ -13,21 +11,18 @@ describe("Button", () => {
let disabledButtonDebugElement: DebugElement;
let linkDebugElement: DebugElement;
beforeEach(waitForAsync(() => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ButtonModule],
declarations: [TestApp],
imports: [TestApp],
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.compileComponents();
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
buttonDebugElement = fixture.debugElement.query(By.css("button"));
disabledButtonDebugElement = fixture.debugElement.query(By.css("button#disabled"));
linkDebugElement = fixture.debugElement.query(By.css("a"));
}));
});
it("should not be disabled when loading and disabled are false", () => {
testAppComponent.loading = false;
@@ -85,10 +80,11 @@ describe("Button", () => {
<button id="disabled" type="button" bitButton disabled>Button</button>
`,
imports: [ButtonModule],
})
class TestApp {
buttonType: string;
block: boolean;
disabled: boolean;
loading: boolean;
buttonType?: string;
block?: boolean;
disabled?: boolean;
loading?: boolean;
}

View File

@@ -52,7 +52,6 @@ const buttonStyles: Record<ButtonType, string[]> = {
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
standalone: true,
imports: [NgClass],
host: {
"[attr.disabled]": "disabledAttr()",

View File

@@ -1,4 +1,12 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import {
Markdown,
Meta,
Canvas,
Primary,
Controls,
Title,
Description,
} from "@storybook/addon-docs";
import * as stories from "./button.stories";
@@ -8,10 +16,9 @@ import * as stories from "./button.stories";
import { ButtonModule } from "@bitwarden/components";
```
# Button
<Title />
Buttons are interactive elements that can be triggered using a mouse, keyboard, or touch. They are
used to indicate actions that can be performed by a user such as submitting a form.
### Default / Secondary
<Primary />
@@ -30,7 +37,7 @@ takes:
### Groups
Groups of buttons should be seperated by a `0.5` rem gap. Usually acomplished by using the
Groups of buttons should be separated by a `0.5` rem gap. Usually accomplished by using the
`tw-gap-2` class in the button group container.
Groups within page content, dialog footers or forms should have the `primary` call to action placed
@@ -41,26 +48,24 @@ right.
There are 3 main styles for the button: Primary, Secondary, and Danger.
### Primary
### Default / Secondary
<Canvas of={stories.Primary} />
The secondary styling(shown above) should be used for secondary calls to action. An action is
"secondary" if it relates indirectly to the purpose of a page. There may be multiple secondary
buttons next to each other; however, generally there should only be 1 or 2 calls to action per page.
### Primary
Use the primary button styling for all Primary call to actions. An action is "primary" if it relates
to the main purpose of a page. There should never be 2 primary styled buttons next to each other.
### Secondary
<Canvas of={stories.Secondary} />
The secondary styling should be used for secondary calls to action. An action is "secondary" if it
relates indirectly to the purpose of a page. There may be multiple secondary buttons next to each
other; however, generally there should only be 1 or 2 calls to action per page.
<Canvas of={stories.Primary} />
### Danger
<Canvas of={stories.Danger} />
Use the danger styling only in settings when the user may perform a permanent destructive action.
Use the danger styling only in settings when the user may preform a permanent action.
<Canvas of={stories.Danger} />
## Disabled UI
@@ -114,7 +119,7 @@ success toast).
### Submit and async actions
Both submit and async action buttons use a loading button state while an action is taken. If your
button is preforming a long running task in the background like a server API call, be sure to review
button is performing a long running task in the background like a server API call, be sure to review
the [Async Actions Directive](?path=/story/component-library-async-actions-overview--page).
<Canvas of={stories.Loading} />

View File

@@ -1,15 +1,15 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ButtonComponent } from "./button.component";
export default {
title: "Component Library/Button",
component: ButtonComponent,
args: {
buttonType: "primary",
disabled: false,
loading: false,
size: "default",
},
argTypes: {
size: {
@@ -27,40 +27,27 @@ export default {
type Story = StoryObj<ButtonComponent>;
export const Primary: Story = {
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
</div>
<div class="tw-flex tw-gap-4 tw-items-center">
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Anchor</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Anchor:hover</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Anchor:active</a>
</div>
<button bitButton ${formatArgsForCodeSnippet<ButtonComponent>(args)}>Button</button>
`,
}),
args: {
buttonType: "primary",
},
};
export const Secondary: Story = {
...Primary,
args: {
buttonType: "secondary",
},
};
export const Primary: Story = {
...Default,
args: {
buttonType: "primary",
},
};
export const Danger: Story = {
...Primary,
...Default,
args: {
buttonType: "danger",
},
@@ -83,16 +70,8 @@ export const Small: Story = {
};
export const Loading: Story = {
render: (args) => ({
props: args,
template: `
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
`,
}),
...Default,
args: {
disabled: false,
loading: true,
},
};
@@ -101,7 +80,6 @@ export const Disabled: Story = {
...Loading,
args: {
disabled: true,
loading: false,
},
};
@@ -110,13 +88,13 @@ export const DisabledWithAttribute: Story = {
props: args,
template: `
@if (disabled) {
<button bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-me-2">Primary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-me-2">Secondary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-me-2">Danger</button>
} @else {
<button bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
<button bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-me-2">Primary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-me-2">Secondary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-me-2">Danger</button>
}
`,
}),
@@ -132,10 +110,10 @@ export const Block: Story = {
template: `
<span class="tw-flex">
<button bitButton [buttonType]="buttonType" [block]="block">[block]="true" Button</button>
<a bitButton [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">[block]="true" Link</a>
<a bitButton [buttonType]="buttonType" [block]="block" href="#" class="tw-ms-2">[block]="true" Link</a>
<button bitButton [buttonType]="buttonType" block class="tw-ml-2">block Button</button>
<a bitButton [buttonType]="buttonType" block href="#" class="tw-ml-2">block Link</a>
<button bitButton [buttonType]="buttonType" block class="tw-ms-2">block Button</button>
<a bitButton [buttonType]="buttonType" block href="#" class="tw-ms-2">block Link</a>
</span>
`,
}),
@@ -165,3 +143,28 @@ export const WithIcon: Story = {
`,
}),
};
export const InteractionStates: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
</div>
<div class="tw-flex tw-gap-4 tw-items-center">
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Anchor</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Anchor:hover</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Anchor:active</a>
</div>
`,
}),
args: {
buttonType: "primary",
},
};

View File

@@ -1,17 +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-pl-3 tw-pr-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">
<header
id="{{ titleId }}"
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",
@@ -24,10 +24,14 @@ const defaultI18n: Partial<Record<CalloutTypes, string>> = {
// Increments for each instance of this component
let nextId = 0;
/**
* Callouts are used to communicate important information to the user. Callouts should be used
* sparingly, as they command a large amount of visual attention. Avoid using more than 1 callout in
* the same location.
*/
@Component({
selector: "bit-callout",
templateUrl: "callout.component.html",
standalone: true,
imports: [SharedModule, TypographyModule],
})
export class CalloutComponent implements OnInit {
@@ -49,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

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./callout.stories";
@@ -8,11 +8,11 @@ import { CalloutModule } from "@bitwarden/components";
<Meta of={stories} />
# Callouts
<Title />
<Description />
Callouts are used to communicate important information to the user. Callouts should be used
sparingly, as they command a large amount of visual attention. Avoid using more than 1 callout in
the same location.
<Primary />
<Controls />
## Styles

View File

@@ -2,6 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { I18nMockService } from "../utils/i18n-mock.service";
import { CalloutComponent } from "./callout.component";
@@ -24,9 +25,6 @@ export default {
],
}),
],
args: {
type: "warning",
},
parameters: {
design: {
type: "figma",
@@ -37,36 +35,35 @@ export default {
type Story = StoryObj<CalloutComponent>;
export const Success: Story = {
export const Info: Story = {
render: (args) => ({
props: args,
template: `
<bit-callout [type]="type" [title]="title">Content</bit-callout>
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>The content of the callout</bit-callout>
`,
}),
args: {
type: "success",
title: "Success",
title: "Callout title",
},
};
export const Info: Story = {
...Success,
export const Success: Story = {
...Info,
args: {
type: "info",
title: "Info",
...Info.args,
type: "success",
},
};
export const Warning: Story = {
...Success,
...Info,
args: {
type: "warning",
},
};
export const Danger: Story = {
...Success,
...Info,
args: {
type: "danger",
},

View File

@@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
selector: "bit-card",
standalone: true,
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {

View File

@@ -9,7 +9,6 @@ import { BitFormControlAbstraction } from "../form-control";
selector: "input[type=checkbox][bitCheckbox]",
template: "",
providers: [{ provide: BitFormControlAbstraction, useExisting: CheckboxComponent }],
standalone: true,
})
export class CheckboxComponent implements BitFormControlAbstraction {
@HostBinding("class")
@@ -27,7 +26,7 @@ export class CheckboxComponent implements BitFormControlAbstraction {
"tw-border-secondary-500",
"tw-h-[1.12rem]",
"tw-w-[1.12rem]",
"tw-mr-1.5",
"tw-me-1.5",
"tw-flex-none", // Flexbox fix for bit-form-control
"before:tw-content-['']",

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./checkbox.stories";
@@ -8,7 +8,8 @@ import * as stories from "./checkbox.stories";
import { CheckboxModule } from "@bitwarden/components";
```
# Checkbox
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -197,15 +197,15 @@ export const Custom: Story = {
<div class="tw-flex tw-flex-col tw-w-32">
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
A-Z
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
a-z
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
0-9
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
</div>
`,
@@ -232,7 +232,7 @@ export const InTableRow: Story = {
type="checkbox"
bitCheckbox
id="checkAll"
class="tw-mr-2"
class="tw-me-2"
/>
<label for="checkAll" class="tw-mb-0">
All

View File

@@ -14,7 +14,7 @@
<!-- Primary button -->
<button
type="button"
class="tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-pl-3 last:tw-pr-3 [&:not(:last-child)]:tw-pr-0 tw-truncate tw-text-[color:inherit] tw-text-[length:inherit]"
class="tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-ps-3 last:tw-pe-3 [&:not(:last-child)]:tw-pe-0 tw-truncate tw-text-[color:inherit] tw-text-[length:inherit]"
data-fvw-target
[ngClass]="{
'tw-cursor-not-allowed': disabled,
@@ -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>
@@ -45,13 +46,13 @@
type="button"
[attr.aria-label]="'removeItem' | i18n: label"
[disabled]="disabled"
class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-mr-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent"
class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-me-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent"
[ngClass]="{
'tw-cursor-not-allowed': disabled,
}"
(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

@@ -33,10 +33,12 @@ export type ChipSelectOption<T> = Option<T> & {
children?: ChipSelectOption<T>[];
};
/**
* `<bit-chip-select>` is a select element that is commonly used to filter items in lists or tables.
*/
@Component({
selector: "bit-chip-select",
templateUrl: "chip-select.component.html",
standalone: true,
imports: [SharedModule, ButtonModule, IconButtonModule, MenuModule, TypographyModule],
providers: [
{

View File

@@ -1,4 +1,4 @@
import { Meta, Primary, Controls, Canvas } from "@storybook/addon-docs";
import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs";
import * as stories from "./chip-select.stories";
@@ -8,9 +8,8 @@ import * as stories from "./chip-select.stories";
import { ChipSelectComponent } from "@bitwarden/components";
```
# Chip Select
`<bit-chip-select>` is a select element that is commonly used to filter items in lists or tables.
<Title />
<Description />
<Canvas of={stories.Default} />

View File

@@ -10,7 +10,11 @@ enum CharacterType {
Special,
Number,
}
/**
* The color password is used primarily in the Generator pages and in the Login type form. It includes
* the logic for displaying letters as `text-main`, numbers as `primary`, and special symbols as
* `danger`.
*/
@Component({
selector: "bit-color-password",
template: `@for (character of passwordCharArray(); track $index; let i = $index) {
@@ -21,7 +25,6 @@ enum CharacterType {
}
</span>
}`,
standalone: true,
})
export class ColorPasswordComponent {
password = input<string>("");

View File

@@ -1,14 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./color-password.stories";
<Meta of={stories} />
# Color password
```ts
import { ColorPasswordModule } from "@bitwarden/components";
```
The color password is used primarily in the Generator pages and in the Login type form. It includes
the logic for displaying letters as `text-main`, numbers as `primary`, and special symbols as
`danger`.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ColorPasswordComponent } from "./color-password.component";
const examplePassword = "Wq$Jk😀7j DX#rS5Sdi!z ";
@@ -25,7 +27,7 @@ export const ColorPassword: Story = {
render: (args) => ({
props: args,
template: `
<bit-color-password class="tw-text-base" [password]="password" [showCount]="showCount"></bit-color-password>
<bit-color-password ${formatArgsForCodeSnippet<ColorPasswordComponent>(args)}></bit-color-password>
`,
}),
};
@@ -35,7 +37,7 @@ export const WrappedColorPassword: Story = {
props: args,
template: `
<div class="tw-max-w-32">
<bit-color-password class="tw-text-base" [password]="password" [showCount]="showCount"></bit-color-password>
<bit-color-password ${formatArgsForCodeSnippet<ColorPasswordComponent>(args)}></bit-color-password>
</div>
`,
}),

View File

@@ -6,6 +6,5 @@ import { Component } from "@angular/core";
@Component({
selector: "bit-container",
templateUrl: "container.component.html",
standalone: true,
})
export class ContainerComponent {}

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";
@@ -21,7 +22,6 @@ import { CopyClickDirective } from "./copy-click.directive";
#toastWithLabel
></button>
`,
standalone: true,
imports: [CopyClickDirective],
})
class TestCopyClickComponent {
@@ -35,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],
@@ -56,6 +58,7 @@ describe("CopyClickDirective", () => {
},
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
{ provide: ToastService, useValue: { showToast } },
{ provide: COPY_CLICK_LISTENER, useValue: copyClickListener },
],
}).compileComponents();
@@ -93,7 +96,6 @@ describe("CopyClickDirective", () => {
successToastButton.click();
expect(showToast).toHaveBeenCalledWith({
message: "copySuccessful",
title: null,
variant: "success",
});
});
@@ -104,7 +106,6 @@ describe("CopyClickDirective", () => {
infoToastButton.click();
expect(showToast).toHaveBeenCalledWith({
message: "copySuccessful",
title: null,
variant: "info",
});
});
@@ -116,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,15 +1,21 @@
// 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]",
standalone: true,
})
export class CopyClickDirective {
private _showToast = false;
@@ -19,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 = "";
@@ -27,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
@@ -55,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)
@@ -62,7 +73,6 @@ export class CopyClickDirective {
this.toastService.showToast({
variant: this.toastVariant,
title: null,
message,
});
}

View File

@@ -1,25 +1,34 @@
import { DIALOG_DATA, DialogModule, DialogRef } from "@angular/cdk/dialog";
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
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 { DialogComponent } from "./dialog/dialog.component";
import { DialogModule } from "./dialog.module";
import { DialogService } from "./dialog.service";
import { DialogCloseDirective } from "./directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "./directives/dialog-title-container.directive";
interface Animal {
animal: string;
}
@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 {
constructor(public dialogService: DialogService) {}
@@ -31,6 +40,14 @@ class StoryDialogComponent {
},
});
}
openDrawer() {
this.dialogService.openDrawer(StoryDialogContentComponent, {
data: {
animal: "panda",
},
});
}
}
@Component({
@@ -49,6 +66,7 @@ class StoryDialogComponent {
</ng-container>
</bit-dialog>
`,
imports: [DialogModule, ButtonModule],
})
class StoryDialogContentComponent {
constructor(
@@ -65,25 +83,36 @@ export default {
title: "Component Library/Dialogs/Service",
component: StoryDialogComponent,
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
ButtonModule,
NoopAnimationsModule,
DialogModule,
IconButtonModule,
DialogCloseDirective,
DialogComponent,
DialogTitleContainerDirective,
RouterTestingModule,
LayoutComponent,
],
providers: [DialogService],
}),
applicationConfig({
providers: [
provideAnimations(),
DialogService,
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
close: "Close",
loading: "Loading",
search: "Search",
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
yes: "Yes",
no: "No",
});
},
},
@@ -100,4 +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,7 +20,9 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
selector: "bit-dialog",
templateUrl: "./dialog.component.html",
animations: [fadeIn],
standalone: true,
host: {
"(keydown.esc)": "handleEsc($event)",
},
imports: [
CommonModule,
DialogTitleContainerDirective,
@@ -24,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";
@@ -64,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

@@ -89,7 +89,7 @@ export const Default: Story = {
<button bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
<button
[disabled]="loading"
class="tw-ml-auto"
class="tw-ms-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
@@ -252,7 +252,7 @@ export const WithCards: Story = {
<button bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
<button
[disabled]="loading"
class="tw-ml-auto"
class="tw-ms-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"

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

@@ -3,7 +3,6 @@ import { Directive, HostBinding, HostListener, Input, Optional } from "@angular/
@Directive({
selector: "[bitDialogClose]",
standalone: true,
})
export class DialogCloseDirective {
@Input("bitDialogClose") dialogResult: any;

View File

@@ -6,7 +6,6 @@ let nextId = 0;
@Directive({
selector: "[bitDialogTitleContainer]",
standalone: true,
})
export class DialogTitleContainerDirective implements OnInit {
@HostBinding("id") id = `bit-dialog-title-${nextId++}`;

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

@@ -30,7 +30,6 @@ const DEFAULT_COLOR: Record<SimpleDialogType, string> = {
@Component({
templateUrl: "./simple-configurable-dialog.component.html",
standalone: true,
imports: [
ReactiveFormsModule,
BitSubmitDirective,

View File

@@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -31,6 +31,7 @@ import { DialogModule } from "../../dialog.module";
</bit-callout>
}
`,
imports: [ButtonModule, CalloutModule, DialogModule],
})
class StoryDialogComponent {
protected dialogs: { title: string; dialogs: SimpleDialogOptions[] }[] = [
@@ -146,11 +147,9 @@ export default {
title: "Component Library/Dialogs/Service/SimpleConfigurable",
component: StoryDialogComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, BrowserAnimationsModule, DialogModule, CalloutModule],
}),
applicationConfig({
providers: [
provideAnimations(),
{
provide: I18nService,
useFactory: () => {

View File

@@ -1,8 +1,8 @@
<div
class="tw-my-4 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-text-contrast tw-text-main"
class="tw-my-4 tw-pb-6 tw-pt-8 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-100 tw-shadow-xl tw-bg-text-contrast tw-text-main"
@fadeIn
>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
<div class="tw-flex tw-px-6 tw-flex-col tw-items-center tw-gap-2 tw-text-center">
@if (!hideIcon()) {
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
@@ -20,13 +20,11 @@
</h1>
</div>
<div
class="tw-overflow-y-auto tw-px-4 tw-pb-4 tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
class="tw-overflow-y-auto tw-px-6 tw-mb-6 tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
>
<ng-content select="[bitDialogContent]"></ng-content>
</div>
<div
class="tw-flex tw-flex-row tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-p-4"
>
<div class="tw-flex tw-flex-col tw-gap-2 tw-px-6">
<ng-content select="[bitDialogFooter]"></ng-content>
</div>
</div>

View File

@@ -6,7 +6,6 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
@Directive({
selector: "[bitDialogIcon]",
standalone: true,
})
export class IconDirective {}
@@ -14,7 +13,6 @@ export class IconDirective {}
selector: "bit-simple-dialog",
templateUrl: "./simple-dialog.component.html",
animations: [fadeIn],
standalone: true,
imports: [DialogTitleContainerDirective, TypographyDirective],
})
export class SimpleDialogComponent {

View File

@@ -1,13 +1,11 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../../button";
import { IconButtonModule } from "../../icon-button";
import { SharedModule } from "../../shared/shared.module";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { DialogModule } from "../dialog.module";
import { DialogService } from "../dialog.service";
@@ -18,6 +16,7 @@ interface Animal {
@Component({
template: `<button type="button" bitButton (click)="openDialog()">Open Simple Dialog</button>`,
imports: [ButtonModule],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
@@ -48,6 +47,7 @@ class StoryDialogComponent {
</ng-container>
</bit-simple-dialog>
`,
imports: [ButtonModule, DialogModule],
})
class StoryDialogContentComponent {
constructor(
@@ -65,15 +65,8 @@ export default {
component: StoryDialogComponent,
decorators: [
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
IconButtonModule,
ButtonModule,
BrowserAnimationsModule,
DialogModule,
],
providers: [
provideAnimations(),
DialogService,
{
provide: I18nService,

View File

@@ -7,7 +7,6 @@ import { DisclosureComponent } from "./disclosure.component";
@Directive({
selector: "[bitDisclosureTriggerFor]",
exportAs: "disclosureTriggerFor",
standalone: true,
})
export class DisclosureTriggerForDirective {
/**

View File

@@ -11,9 +11,32 @@ import {
let nextId = 0;
/**
* The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to create an accessible content area whose visibility is controlled by a trigger button.
* To compose a disclosure and trigger:
* 1. Create a trigger component (see "Supported Trigger Components" section below)
* 2. Create a `bit-disclosure`
* 3. Set a template reference on the `bit-disclosure`
* 4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the `bit-disclosure` template reference
* 5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to being hidden.
*
* @example
*
* ```html
* <button
* type="button"
* bitIconButton="bwi-sliders"
* [buttonType]="'muted'"
* [bitDisclosureTriggerFor]="disclosureRef"
* ></button>
* <bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
* ```
*
*/
@Component({
selector: "bit-disclosure",
standalone: true,
template: `<ng-content></ng-content>`,
})
export class DisclosureComponent {

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./disclosure.stories";
@@ -8,37 +8,11 @@ import * as stories from "./disclosure.stories";
import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components";
```
# Disclosure
The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to
create an accessible content area whose visibility is controlled by a trigger button.
To compose a disclosure and trigger:
1. Create a trigger component (see "Supported Trigger Components" section below)
2. Create a `bit-disclosure`
3. Set a template reference on the `bit-disclosure`
4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the
`bit-disclosure` template reference
5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently
expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to
being hidden.
```
<button
type="button"
bitIconButton="bwi-sliders"
[buttonType]="'muted'"
[bitDisclosureTriggerFor]="disclosureRef"
></button>
<bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
```
<Title />
<Description />
<Canvas of={stories.DisclosureWithIconButton} />
<br />
<br />
## Supported Trigger Components
This is the list of currently supported trigger components:

View File

@@ -1,20 +1,19 @@
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`
*/
@Component({
selector: "bit-drawer-body",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
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: [
{
@@ -24,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

@@ -15,7 +15,6 @@ import { DrawerComponent } from "./drawer.component";
**/
@Directive({
selector: "button[bitDrawerClose]",
standalone: true,
host: {
"(click)": "onClick()",
},

View File

@@ -13,12 +13,11 @@ import { DrawerCloseDirective } from "./drawer-close.directive";
**/
@Component({
selector: "bit-drawer-header",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DrawerCloseDirective, TypographyModule, IconButtonModule, I18nPipe],
templateUrl: "drawer-header.component.html",
host: {
class: "tw-block tw-pl-4 tw-pr-2 tw-py-2",
class: "tw-block tw-ps-4 tw-pe-2 tw-py-2",
},
})
export class DrawerHeaderComponent {

View File

@@ -8,7 +8,6 @@ import { Directive, signal } from "@angular/core";
*/
@Directive({
selector: "[bitDrawerHost]",
standalone: true,
})
export class DrawerHostDirective {
private _portal = signal<Portal<unknown> | undefined>(undefined);

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.
@@ -19,13 +19,12 @@ import { DrawerHostDirective } from "./drawer-host.directive";
*/
@Component({
selector: "bit-drawer",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, PortalModule],
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

@@ -21,7 +21,7 @@
</span>
</label>
@if (hasError) {
<div class="tw-mt-1 tw-text-danger tw-text-xs tw-ml-0.5">
<div class="tw-mt-1 tw-text-danger tw-text-xs tw-ms-0.5">
<i class="bwi bwi-error"></i> {{ displayError }}
</div>
}

View File

@@ -14,7 +14,6 @@ import { BitFormControlAbstraction } from "./form-control.abstraction";
@Component({
selector: "bit-form-control",
templateUrl: "form-control.component.html",
standalone: true,
imports: [NgClass, TypographyDirective, I18nPipe],
})
export class FormControlComponent {
@@ -40,7 +39,7 @@ export class FormControlComponent {
@HostBinding("class") get classes() {
return []
.concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"])
.concat(this.inline ? ["tw-inline-block", "tw-me-4"] : ["tw-block"])
.concat(this.disableMargin ? [] : ["tw-mb-4"]);
}

View File

@@ -8,7 +8,6 @@ let nextId = 0;
host: {
class: "tw-text-muted tw-font-normal tw-inline-block tw-mt-1 tw-text-xs",
},
standalone: true,
})
export class BitHintComponent {
@HostBinding() id = `bit-hint-${nextId++}`;

View File

@@ -10,7 +10,6 @@ let nextId = 0;
@Component({
selector: "bit-label",
standalone: true,
templateUrl: "label.component.html",
imports: [CommonModule],
})

View File

@@ -15,7 +15,6 @@ import { I18nPipe } from "@bitwarden/ui-common";
class: "tw-block tw-text-danger tw-mt-2",
"aria-live": "assertive",
},
standalone: true,
imports: [I18nPipe],
})
export class BitErrorSummary {

View File

@@ -14,7 +14,6 @@ let nextId = 0;
class: "tw-block tw-mt-1 tw-text-danger tw-text-xs",
"aria-live": "assertive",
},
standalone: true,
})
export class BitErrorComponent {
@HostBinding() id = `bit-error-${nextId++}`;

View File

@@ -20,7 +20,7 @@
<div class="tw-absolute tw-size-full tw-top-0 tw-pointer-events-none tw-z-20">
<div class="tw-size-full tw-flex">
<div
class="tw-min-w-3 tw-border-r-0 group-focus-within/bit-form-field:tw-border-r-0 !tw-rounded-l-lg"
class="tw-min-w-3 tw-border-r-0 group-focus-within/bit-form-field:tw-border-r-0 !tw-rounded-s-lg"
[ngClass]="inputBorderClasses"
></div>
<div
@@ -40,7 +40,7 @@
</label>
</div>
<div
class="tw-min-w-3 tw-grow tw-border-l-0 group-focus-within/bit-form-field:tw-border-l-0 !tw-rounded-r-lg"
class="tw-min-w-3 tw-grow tw-border-l-0 group-focus-within/bit-form-field:tw-border-l-0 !tw-rounded-e-lg"
[ngClass]="inputBorderClasses"
></div>
</div>
@@ -50,7 +50,7 @@
>
<div
#prefixContainer
class="tw-flex tw-items-center tw-gap-1 tw-pl-3 tw-py-2"
class="tw-flex tw-items-center tw-gap-1 tw-ps-3 tw-py-2"
[hidden]="!prefixHasChildren()"
>
<ng-container *ngTemplateOutlet="prefixContent"></ng-container>
@@ -59,15 +59,15 @@
class="tw-w-full tw-relative tw-py-2 has-[bit-select]:tw-p-0 has-[bit-multi-select]:tw-p-0 has-[input:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea:read-only:not([hidden])]:tw-bg-secondary-100"
data-default-content
[ngClass]="[
prefixHasChildren() ? '' : 'tw-rounded-l-lg tw-pl-3',
suffixHasChildren() ? '' : 'tw-rounded-r-lg tw-pr-3',
prefixHasChildren() ? '' : 'tw-rounded-s-lg tw-ps-3',
suffixHasChildren() ? '' : 'tw-rounded-e-lg tw-pe-3',
]"
>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</div>
<div
#suffixContainer
class="tw-flex tw-items-center tw-gap-1 tw-pr-3 tw-py-2"
class="tw-flex tw-items-center tw-gap-1 tw-pe-3 tw-py-2"
[hidden]="!suffixHasChildren()"
>
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
@@ -92,7 +92,7 @@
<div
#prefixContainer
[hidden]="!prefixHasChildren()"
class="tw-flex tw-items-center tw-gap-1 tw-pl-1"
class="tw-flex tw-items-center tw-gap-1 tw-ps-1"
>
<ng-container *ngTemplateOutlet="prefixContent"></ng-container>
</div>
@@ -105,7 +105,7 @@
<div
#suffixContainer
[hidden]="!suffixHasChildren()"
class="tw-flex tw-items-center tw-gap-1 tw-pr-1"
class="tw-flex tw-items-center tw-gap-1 tw-pe-1"
>
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
</div>

View File

@@ -26,7 +26,6 @@ import { BitFormFieldControl } from "./form-field-control";
@Component({
selector: "bit-form-field",
templateUrl: "./form-field.component.html",
standalone: true,
imports: [CommonModule, BitErrorComponent, I18nPipe],
})
export class BitFormFieldComponent implements AfterContentChecked {

View File

@@ -18,7 +18,6 @@ import { BitFormFieldComponent } from "./form-field.component";
@Directive({
selector: "[bitPasswordInputToggle]",
standalone: true,
})
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
/**

View File

@@ -6,7 +6,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { IconButtonModule } from "../icon-button";
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BitFormFieldControl } from "./form-field-control";
@@ -25,6 +24,7 @@ import { BitPasswordInputToggleDirective } from "./password-input-toggle.directi
</bit-form-field>
</form>
`,
imports: [FormFieldModule, IconButtonModule],
})
class TestFormFieldComponent {}
@@ -36,8 +36,7 @@ describe("PasswordInputToggle", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormFieldModule, IconButtonModule, InputModule],
declarations: [TestFormFieldComponent],
imports: [TestFormFieldComponent],
providers: [
{
provide: I18nService,

View File

@@ -4,7 +4,6 @@ import { BitIconButtonComponent } from "../icon-button/icon-button.component";
@Directive({
selector: "[bitPrefix]",
standalone: true,
})
export class BitPrefixDirective implements OnInit {
@HostBinding("class") @Input() get classList() {

View File

@@ -4,7 +4,6 @@ import { BitIconButtonComponent } from "../icon-button/icon-button.component";
@Directive({
selector: "[bitSuffix]",
standalone: true,
})
export class BitSuffixDirective implements OnInit {
@HostBinding("class") @Input() get classList() {

View File

@@ -147,7 +147,13 @@ const sizes: Record<IconButtonSize, string[]> = {
default: ["tw-px-2.5", "tw-py-1.5"],
small: ["tw-leading-none", "tw-text-base", "tw-p-1"],
};
/**
* Icon buttons are used when no text accompanies the button. It consists of an icon that may be updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`.
* The most common use of the icon button is in the banner, toast, and modal components as a close button. It can also be found in tables as the 3 dot option menu, or on navigation list items when there are options that need to be collapsed into a menu.
* Similar to the main button components, spacing between multiple icon buttons should be .5rem.
*/
@Component({
selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html",
@@ -155,7 +161,6 @@ const sizes: Record<IconButtonSize, string[]> = {
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
],
standalone: true,
imports: [NgClass],
host: {
"[attr.disabled]": "disabledAttr()",

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./icon-button.stories";
@@ -8,16 +8,8 @@ import * as stories from "./icon-button.stories";
import { IconButtonModule } from "@bitwarden/components";
```
# Icon Button
Icon buttons are used when no text accompanies the button. It consists of an icon that may be
updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`.
The most common use of the icon button is in the banner, toast, and modal components as a close
button. It can also be found in tables as the 3 dot option menu, or on navigation list items when
there are options that need to be collapsed into a menu.
Similar to the main button components, spacing between multiple icon buttons should be .5rem.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { BitIconButtonComponent } from "./icon-button.component";
export default {
@@ -7,8 +9,11 @@ export default {
component: BitIconButtonComponent,
args: {
bitIconButton: "bwi-plus",
size: "default",
disabled: false,
},
argTypes: {
buttonType: {
options: ["primary", "secondary", "danger", "unstyled", "contrast", "main", "muted", "light"],
},
},
parameters: {
design: {
@@ -24,25 +29,9 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<div class="tw-space-x-4">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="main" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="muted" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="primary" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="secondary"[size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="danger" [size]="size">Button</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="contrast" [size]="size">Button</button>
</div>
<div class="tw-bg-background-alt2 tw-p-2 tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="light" [size]="size">Button</button>
</div>
</div>
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
`,
}),
args: {
size: "default",
buttonType: "primary",
},
};
export const Small: Story = {
@@ -54,40 +43,35 @@ export const Small: Story = {
};
export const Primary: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
`,
}),
...Default,
args: {
buttonType: "primary",
},
};
export const Secondary: Story = {
...Primary,
...Default,
args: {
buttonType: "secondary",
},
};
export const Danger: Story = {
...Primary,
...Default,
args: {
buttonType: "danger",
},
};
export const Main: Story = {
...Primary,
...Default,
args: {
buttonType: "main",
},
};
export const Muted: Story = {
...Primary,
...Default,
args: {
buttonType: "muted",
},
@@ -98,7 +82,8 @@ export const Light: Story = {
props: args,
template: /*html*/ `
<div class="tw-bg-background-alt2 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
<!-- <div> used only to provide dark background color -->
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
</div>
`,
}),
@@ -112,7 +97,8 @@ export const Contrast: Story = {
props: args,
template: /*html*/ `
<div class="tw-bg-primary-600 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
<!-- <div> used only to provide dark background color -->
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
</div>
`,
}),

View File

@@ -11,7 +11,6 @@ import { Icon, isIcon } from "./icon";
"[innerHtml]": "innerHtml",
},
template: ``,
standalone: true,
})
export class BitIconComponent {
innerHtml: SafeHtml | null = null;

View File

@@ -4,6 +4,10 @@ import * as stories from "./icon.stories";
<Meta of={stories} />
```ts
import { IconModule } from "@bitwarden/components";
```
# Icon Use Instructions
- Icons will generally be attached to the associated Jira task.

Some files were not shown because too many files have changed in this diff Show More