mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
resolve merge conflicts
This commit is contained in:
@@ -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>/../../",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { A11yRowDirective } from "./a11y-row.directive";
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitA11yGrid]",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yGridDirective {
|
||||
private viewPort = inject(CdkVirtualScrollViewport, { optional: true });
|
||||
|
||||
@@ -11,7 +11,6 @@ import { A11yCellDirective } from "./a11y-cell.directive";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitA11yRow]",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yRowDirective {
|
||||
@HostBinding("attr.role")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
185
libs/components/src/anon-layout/anon-layout-wrapper.component.ts
Normal file
185
libs/components/src/anon-layout/anon-layout-wrapper.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
24
libs/components/src/anon-layout/anon-layout-wrapper.mdx
Normal file
24
libs/components/src/anon-layout/anon-layout-wrapper.mdx
Normal 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} />
|
||||
247
libs/components/src/anon-layout/anon-layout-wrapper.stories.ts
Normal file
247
libs/components/src/anon-layout/anon-layout-wrapper.stories.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
72
libs/components/src/anon-layout/anon-layout.component.html
Normal file
72
libs/components/src/anon-layout/anon-layout.component.html
Normal 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">© {{ year }} Bitwarden Inc.</div>
|
||||
<div bitTypography="body2">{{ version }}</div>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<ng-template #defaultContent>
|
||||
<ng-content></ng-content>
|
||||
</ng-template>
|
||||
86
libs/components/src/anon-layout/anon-layout.component.ts
Normal file
86
libs/components/src/anon-layout/anon-layout.component.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
168
libs/components/src/anon-layout/anon-layout.mdx
Normal file
168
libs/components/src/anon-layout/anon-layout.mdx
Normal 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—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} />
|
||||
250
libs/components/src/anon-layout/anon-layout.stories.ts
Normal file
250
libs/components/src/anon-layout/anon-layout.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
4
libs/components/src/anon-layout/index.ts
Normal file
4
libs/components/src/anon-layout/index.ts
Normal 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";
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { QueryParamsHandling } from "@angular/router";
|
||||
@Component({
|
||||
selector: "bit-breadcrumb",
|
||||
templateUrl: "./breadcrumb.component.html",
|
||||
standalone: true,
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
@Input()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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-['']",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>("");
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,5 @@ import { Component } from "@angular/core";
|
||||
@Component({
|
||||
selector: "bit-container",
|
||||
templateUrl: "container.component.html",
|
||||
standalone: true,
|
||||
})
|
||||
export class ContainerComponent {}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
DEFAULT_DIALOG_CONFIG,
|
||||
Dialog,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DIALOG_SCROLL_STRATEGY,
|
||||
Dialog as CdkDialog,
|
||||
DialogConfig as CdkDialogConfig,
|
||||
DialogRef as CdkDialogRefBase,
|
||||
DIALOG_DATA,
|
||||
DialogCloseOptions,
|
||||
} from "@angular/cdk/dialog";
|
||||
import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay";
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
Optional,
|
||||
SkipSelf,
|
||||
TemplateRef,
|
||||
} from "@angular/core";
|
||||
import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay";
|
||||
import { ComponentPortal, Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
|
||||
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
|
||||
import { SimpleDialogOptions, Translation } from "./simple-dialog/types";
|
||||
import { SimpleDialogOptions } from "./simple-dialog/types";
|
||||
|
||||
/**
|
||||
* The default `BlockScrollStrategy` does not work well with virtual scrolling.
|
||||
@@ -48,61 +42,163 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
|
||||
detach() {}
|
||||
}
|
||||
|
||||
export abstract class DialogRef<R = unknown, C = unknown>
|
||||
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
|
||||
{
|
||||
abstract readonly isDrawer?: boolean;
|
||||
|
||||
// --- From CdkDialogRef ---
|
||||
abstract close(result?: R, options?: DialogCloseOptions): void;
|
||||
abstract readonly closed: Observable<R | undefined>;
|
||||
abstract disableClose: boolean | undefined;
|
||||
/**
|
||||
* @deprecated
|
||||
* Does not work with drawer dialogs.
|
||||
**/
|
||||
abstract componentInstance: C | null;
|
||||
}
|
||||
|
||||
export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
CdkDialogConfig<D, R>,
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
|
||||
>;
|
||||
|
||||
class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = true;
|
||||
|
||||
private _closed = new Subject<R | undefined>();
|
||||
closed = this._closed.asObservable();
|
||||
disableClose = false;
|
||||
|
||||
/** The portal containing the drawer */
|
||||
portal?: Portal<unknown>;
|
||||
|
||||
constructor(private drawerService: DrawerService) {}
|
||||
|
||||
close(result?: R, _options?: DialogCloseOptions): void {
|
||||
if (this.disableClose) {
|
||||
return;
|
||||
}
|
||||
this.drawerService.close(this.portal!);
|
||||
this._closed.next(result);
|
||||
this._closed.complete();
|
||||
}
|
||||
|
||||
componentInstance: C | null = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DialogRef that delegates functionality to the CDK implementation
|
||||
**/
|
||||
export class CdkDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = false;
|
||||
|
||||
/** This is not available until after construction, @see DialogService.open. */
|
||||
cdkDialogRefBase!: CdkDialogRefBase<R, C>;
|
||||
|
||||
// --- Delegated to CdkDialogRefBase ---
|
||||
|
||||
close(result?: R, options?: DialogCloseOptions): void {
|
||||
this.cdkDialogRefBase.close(result, options);
|
||||
}
|
||||
|
||||
get closed(): Observable<R | undefined> {
|
||||
return this.cdkDialogRefBase.closed;
|
||||
}
|
||||
|
||||
get disableClose(): boolean | undefined {
|
||||
return this.cdkDialogRefBase.disableClose;
|
||||
}
|
||||
set disableClose(value: boolean | undefined) {
|
||||
this.cdkDialogRefBase.disableClose = value;
|
||||
}
|
||||
|
||||
// Delegate the `componentInstance` property to the CDK DialogRef
|
||||
get componentInstance(): C | null {
|
||||
return this.cdkDialogRefBase.componentInstance;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DialogService extends Dialog implements OnDestroy {
|
||||
private _destroy$ = new Subject<void>();
|
||||
export class DialogService {
|
||||
private dialog = inject(CdkDialog);
|
||||
private drawerService = inject(DrawerService);
|
||||
private injector = inject(Injector);
|
||||
private router = inject(Router, { optional: true });
|
||||
private authService = inject(AuthService, { optional: true });
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"];
|
||||
|
||||
private defaultScrollStrategy = new CustomBlockScrollStrategy();
|
||||
private activeDrawer: DrawerDialogRef<any, any> | null = null;
|
||||
|
||||
constructor(
|
||||
/** Parent class constructor */
|
||||
_overlay: Overlay,
|
||||
_injector: Injector,
|
||||
@Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig,
|
||||
@Optional() @SkipSelf() _parentDialog: Dialog,
|
||||
_overlayContainer: OverlayContainer,
|
||||
@Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any,
|
||||
|
||||
/** Not in parent class */
|
||||
@Optional() router: Router,
|
||||
@Optional() authService: AuthService,
|
||||
|
||||
protected i18nService: I18nService,
|
||||
) {
|
||||
super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy);
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* TODO: This logic should exist outside of `libs/components`.
|
||||
* @see https://bitwarden.atlassian.net/browse/CL-657
|
||||
**/
|
||||
/** Close all open dialogs if the vault locks */
|
||||
if (router && authService) {
|
||||
router.events
|
||||
if (this.router && this.authService) {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
switchMap(() => authService.getAuthStatus()),
|
||||
switchMap(() => this.authService!.getAuthStatus()),
|
||||
filter((v) => v !== AuthenticationStatus.Unlocked),
|
||||
takeUntil(this._destroy$),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(() => this.closeAll());
|
||||
}
|
||||
}
|
||||
|
||||
override ngOnDestroy(): void {
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
override open<R = unknown, D = unknown, C = unknown>(
|
||||
open<R = unknown, D = unknown, C = unknown>(
|
||||
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
|
||||
config?: DialogConfig<D, DialogRef<R, C>>,
|
||||
): DialogRef<R, C> {
|
||||
config = {
|
||||
/**
|
||||
* This is a bit circular in nature:
|
||||
* We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`,
|
||||
* but we get the base CDK DialogRef instance *from* `Dialog.open`.
|
||||
*
|
||||
* To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase.
|
||||
* This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance".
|
||||
**/
|
||||
const ref = new CdkDialogRef<R, C>();
|
||||
const injector = this.createInjector({
|
||||
data: config?.data,
|
||||
dialogRef: ref,
|
||||
});
|
||||
|
||||
// Merge the custom config with the default config
|
||||
const _config = {
|
||||
backdropClass: this.backDropClasses,
|
||||
scrollStrategy: this.defaultScrollStrategy,
|
||||
injector,
|
||||
...config,
|
||||
};
|
||||
|
||||
return super.open(componentOrTemplateRef, config);
|
||||
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** Opens a dialog in the side drawer */
|
||||
openDrawer<R = unknown, D = unknown, C = unknown>(
|
||||
component: ComponentType<C>,
|
||||
config?: DialogConfig<D, DialogRef<R, C>>,
|
||||
): DialogRef<R, C> {
|
||||
this.activeDrawer?.close();
|
||||
/**
|
||||
* This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector.
|
||||
* Similar to `this.open`, we get around this with mutability.
|
||||
*/
|
||||
this.activeDrawer = new DrawerDialogRef(this.drawerService);
|
||||
const portal = new ComponentPortal(
|
||||
component,
|
||||
null,
|
||||
this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }),
|
||||
);
|
||||
this.activeDrawer.portal = portal;
|
||||
this.drawerService.open(portal);
|
||||
return this.activeDrawer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +209,7 @@ export class DialogService extends Dialog implements OnDestroy {
|
||||
*/
|
||||
async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise<boolean> {
|
||||
const dialogRef = this.openSimpleDialogRef(simpleDialogOptions);
|
||||
|
||||
return firstValueFrom(dialogRef.closed);
|
||||
return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,20 +229,29 @@ export class DialogService extends Dialog implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
protected translate(translation: string | Translation, defaultKey?: string): string {
|
||||
if (translation == null && defaultKey == null) {
|
||||
return null;
|
||||
}
|
||||
/** Close all open dialogs */
|
||||
closeAll(): void {
|
||||
return this.dialog.closeAll();
|
||||
}
|
||||
|
||||
if (translation == null) {
|
||||
return this.i18nService.t(defaultKey);
|
||||
}
|
||||
|
||||
// Translation interface use implies we must localize.
|
||||
if (typeof translation === "object") {
|
||||
return this.i18nService.t(translation.key, ...(translation.placeholders ?? []));
|
||||
}
|
||||
|
||||
return translation;
|
||||
/** The injector that is passed to the opened dialog */
|
||||
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
|
||||
return Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: opts.data,
|
||||
},
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: opts.dialogRef,
|
||||
},
|
||||
{
|
||||
provide: CdkDialogRefBase,
|
||||
useValue: opts.dialogRef,
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
@let isDrawer = dialogRef?.isDrawer;
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="width"
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl']"
|
||||
@fadeIn
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-p-6 tw-pb-4': isDrawer,
|
||||
'tw-border-secondary-300': showHeaderBorder,
|
||||
'tw-border-transparent': !showHeaderBorder,
|
||||
}"
|
||||
>
|
||||
<h1
|
||||
<h2
|
||||
bitDialogTitleContainer
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
@@ -19,7 +29,7 @@
|
||||
</span>
|
||||
}
|
||||
<ng-content select="[bitDialogTitle]"></ng-content>
|
||||
</h1>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
@@ -32,9 +42,11 @@
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="tw-relative tw-flex tw-flex-col tw-overflow-hidden"
|
||||
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
|
||||
[ngClass]="{
|
||||
'tw-min-h-60': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
}"
|
||||
>
|
||||
@if (loading) {
|
||||
@@ -43,20 +55,28 @@
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
cdkScrollable
|
||||
[ngClass]="{
|
||||
'tw-p-4': !disablePadding,
|
||||
'tw-p-4': !disablePadding && !isDrawer,
|
||||
'tw-px-6 tw-py-4': !disablePadding && isDrawer,
|
||||
'tw-overflow-y-auto': !loading,
|
||||
'tw-invisible tw-overflow-y-hidden': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-4"
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
|
||||
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
|
||||
[ngClass]="{
|
||||
'tw-px-6 tw-py-4': isDrawer,
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-border-secondary-300': showFooterBorder,
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogFooter]"></ng-content>
|
||||
</footer>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
|
||||
import { fadeIn } from "../animations";
|
||||
import { DialogRef } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
|
||||
@@ -16,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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Directive, HostBinding, HostListener, Input, Optional } from "@angular/
|
||||
|
||||
@Directive({
|
||||
selector: "[bitDialogClose]",
|
||||
standalone: true,
|
||||
})
|
||||
export class DialogCloseDirective {
|
||||
@Input("bitDialogClose") dialogResult: any;
|
||||
|
||||
@@ -6,7 +6,6 @@ let nextId = 0;
|
||||
|
||||
@Directive({
|
||||
selector: "[bitDialogTitleContainer]",
|
||||
standalone: true,
|
||||
})
|
||||
export class DialogTitleContainerDirective implements OnInit {
|
||||
@HostBinding("id") id = `bit-dialog-title-${nextId++}`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -30,7 +30,6 @@ const DEFAULT_COLOR: Record<SimpleDialogType, string> = {
|
||||
|
||||
@Component({
|
||||
templateUrl: "./simple-configurable-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
BitSubmitDirective,
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DisclosureComponent } from "./disclosure.component";
|
||||
@Directive({
|
||||
selector: "[bitDisclosureTriggerFor]",
|
||||
exportAs: "disclosureTriggerFor",
|
||||
standalone: true,
|
||||
})
|
||||
export class DisclosureTriggerForDirective {
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { DrawerComponent } from "./drawer.component";
|
||||
**/
|
||||
@Directive({
|
||||
selector: "button[bitDrawerClose]",
|
||||
standalone: true,
|
||||
host: {
|
||||
"(click)": "onClick()",
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,8 @@ import { DrawerComponent } from "@bitwarden/components";
|
||||
|
||||
# Drawer
|
||||
|
||||
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
|
||||
|
||||
A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
|
||||
<Primary />
|
||||
|
||||
20
libs/components/src/drawer/drawer.service.ts
Normal file
20
libs/components/src/drawer/drawer.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, signal } from "@angular/core";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class DrawerService {
|
||||
private _portal = signal<Portal<unknown> | undefined>(undefined);
|
||||
|
||||
/** The portal to display */
|
||||
portal = this._portal.asReadonly();
|
||||
|
||||
open(portal: Portal<unknown>) {
|
||||
this._portal.set(portal);
|
||||
}
|
||||
|
||||
close(portal: Portal<unknown>) {
|
||||
if (portal === this.portal()) {
|
||||
this._portal.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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++}`;
|
||||
|
||||
@@ -10,7 +10,6 @@ let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-label",
|
||||
standalone: true,
|
||||
templateUrl: "label.component.html",
|
||||
imports: [CommonModule],
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { BitFormFieldComponent } from "./form-field.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitPasswordInputToggle]",
|
||||
standalone: true,
|
||||
})
|
||||
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Icon, isIcon } from "./icon";
|
||||
"[innerHtml]": "innerHtml",
|
||||
},
|
||||
template: ``,
|
||||
standalone: true,
|
||||
})
|
||||
export class BitIconComponent {
|
||||
innerHtml: SafeHtml | null = null;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user