1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 19:11:22 +00:00

Merge branch 'main' into feature/i18n-component-template

This commit is contained in:
Shane Melton
2024-03-08 12:58:38 -08:00
2636 changed files with 190706 additions and 44060 deletions

View File

@@ -27,7 +27,7 @@ export class BitActionDirective implements OnDestroy {
constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService,
@Optional() private logService?: LogService
@Optional() private logService?: LogService,
) {}
get loading() {
@@ -55,7 +55,7 @@ export class BitActionDirective implements OnDestroy {
},
}),
finalize(() => (this.loading = false)),
takeUntil(this.destroy$)
takeUntil(this.destroy$),
)
.subscribe();
}

View File

@@ -28,7 +28,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
constructor(
private formGroupDirective: FormGroupDirective,
@Optional() validationService?: ValidationService,
@Optional() logService?: LogService
@Optional() logService?: LogService,
) {
formGroupDirective.ngSubmit
.pipe(
@@ -46,10 +46,10 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
logService?.error(`Async submit exception: ${err}`);
validationService?.showError(err);
return of(undefined);
})
}),
);
}),
takeUntil(this.destroy$)
takeUntil(this.destroy$),
)
.subscribe({
next: () => (this.loading = false),

View File

@@ -33,7 +33,7 @@ export class BitFormButtonDirective implements OnDestroy {
constructor(
buttonComponent: ButtonLikeAbstraction,
@Optional() submitDirective?: BitSubmitDirective,
@Optional() actionDirective?: BitActionDirective
@Optional() actionDirective?: BitActionDirective,
) {
if (submitDirective && buttonComponent) {
submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {

View File

@@ -1,3 +1,4 @@
export * from "./async-actions.module";
export * from "./bit-action.directive";
export * from "./form-button.directive";
export * from "./bit-submit.directive";

View File

@@ -75,8 +75,10 @@ export class AvatarComponent implements OnChanges {
svg.appendChild(charObj);
const html = window.document.createElement("div").appendChild(svg).outerHTML;
const svgHtml = window.btoa(unescape(encodeURIComponent(html)));
// This is safe because the only user provided value, chars is set using `textContent`
this.src = this.sanitizer.bypassSecurityTrustResourceUrl(
"data:image/svg+xml;base64," + svgHtml
"data:image/svg+xml;base64," + svgHtml,
);
}
@@ -115,8 +117,9 @@ export class AvatarComponent implements OnChanges {
textTag.setAttribute(
"font-family",
'"Open Sans","Helvetica Neue",Helvetica,Arial,' +
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"',
);
// Warning do not use innerHTML here, characters are user provided
textTag.textContent = character;
textTag.style.fontWeight = this.svgFontWeight.toString();
textTag.style.fontSize = this.svgFontSize + "px";

View File

@@ -1,11 +1,11 @@
<div class="tw-inline-flex tw-gap-2">
<div class="tw-inline-flex tw-flex-wrap tw-gap-2">
<ng-container *ngFor="let item of filteredItems; let last = last">
<span bitBadge [badgeType]="badgeType" [truncate]="truncate">
<span bitBadge [variant]="variant" [truncate]="truncate">
{{ item }}
</span>
<span class="tw-sr-only" *ngIf="!last || isFiltered">, </span>
</ng-container>
<span *ngIf="isFiltered" bitBadge [badgeType]="badgeType">
{{ "plusNMore" | i18n : (items.length - filteredItems.length).toString() }}
<span *ngIf="isFiltered" bitBadge [variant]="variant">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
</span>
</div>

View File

@@ -1,6 +1,6 @@
import { Component, Input, OnChanges } from "@angular/core";
import { BadgeTypes } from "../badge";
import { BadgeVariant } from "../badge";
@Component({
selector: "bit-badge-list",
@@ -12,7 +12,7 @@ export class BadgeListComponent implements OnChanges {
protected filteredItems: string[] = [];
protected isFiltered = false;
@Input() badgeType: BadgeTypes = "primary";
@Input() variant: BadgeVariant = "primary";
@Input() items: string[] = [];
@Input() truncate = true;

View File

@@ -28,7 +28,7 @@ export default {
}),
],
args: {
badgeType: "primary",
variant: "primary",
truncate: false,
},
parameters: {
@@ -45,12 +45,12 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<bit-badge-list [badgeType]="badgeType" [maxItems]="maxItems" [items]="items" [truncate]="truncate"></bit-badge-list>
<bit-badge-list [variant]="variant" [maxItems]="maxItems" [items]="items" [truncate]="truncate"></bit-badge-list>
`,
}),
args: {
badgeType: "info",
variant: "info",
maxItems: 3,
items: ["Badge 1", "Badge 2", "Badge 3", "Badge 4", "Badge 5"],
truncate: false,
@@ -60,7 +60,7 @@ export const Default: Story = {
export const Truncated: Story = {
...Default,
args: {
badgeType: "info",
variant: "info",
maxItems: 3,
items: ["Badge 1", "Badge 2 containing lengthy text", "Badge 3", "Badge 4", "Badge 5"],
truncate: true,

View File

@@ -1,8 +1,8 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record<BadgeTypes, string[]> = {
const styles: Record<BadgeVariant, string[]> = {
primary: ["tw-bg-primary-500"],
secondary: ["tw-bg-text-muted"],
success: ["tw-bg-success-500"],
@@ -11,7 +11,7 @@ const styles: Record<BadgeTypes, string[]> = {
info: ["tw-bg-info-500"],
};
const hoverStyles: Record<BadgeTypes, string[]> = {
const hoverStyles: Record<BadgeVariant, string[]> = {
primary: ["hover:tw-bg-primary-700"],
secondary: ["hover:tw-bg-secondary-700"],
success: ["hover:tw-bg-success-700"],
@@ -44,15 +44,22 @@ export class BadgeDirective {
"focus:tw-ring-offset-2",
"focus:tw-ring-primary-700",
]
.concat(styles[this.badgeType])
.concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : [])
.concat(styles[this.variant])
.concat(this.hasHoverEffects ? hoverStyles[this.variant] : [])
.concat(this.truncate ? ["tw-truncate", "tw-max-w-40"] : []);
}
@HostBinding("attr.title") get title() {
return this.truncate ? this.el.nativeElement.textContent.trim() : null;
}
@Input() badgeType: BadgeTypes = "primary";
/**
* Variant, sets the background color of the badge.
*/
@Input() variant: BadgeVariant = "primary";
/**
* Truncate long text
*/
@Input() truncate = true;
private hasHoverEffects = false;

View File

@@ -13,7 +13,7 @@ export default {
}),
],
args: {
badgeType: "primary",
variant: "primary",
truncate: false,
},
parameters: {
@@ -30,11 +30,11 @@ export const Primary: Story = {
render: (args) => ({
props: args,
template: `
<span class="tw-text-main">Span </span><span bitBadge [badgeType]="badgeType" [truncate]="truncate">Badge containing lengthy text</span>
<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 [badgeType]="badgeType" [truncate]="truncate">Badge</a>
<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 [badgeType]="badgeType" [truncate]="truncate">Badge</button>
<span class="tw-text-main">Button </span><button bitBadge [variant]="variant" [truncate]="truncate">Badge</button>
`,
}),
};
@@ -42,35 +42,35 @@ export const Primary: Story = {
export const Secondary: Story = {
...Primary,
args: {
badgeType: "secondary",
variant: "secondary",
},
};
export const Success: Story = {
...Primary,
args: {
badgeType: "success",
variant: "success",
},
};
export const Danger: Story = {
...Primary,
args: {
badgeType: "danger",
variant: "danger",
},
};
export const Warning: Story = {
...Primary,
args: {
badgeType: "warning",
variant: "warning",
},
};
export const Info: Story = {
...Primary,
args: {
badgeType: "info",
variant: "info",
},
};

View File

@@ -1,2 +1,2 @@
export { BadgeDirective, BadgeTypes } from "./badge.directive";
export { BadgeDirective, BadgeVariant } from "./badge.directive";
export * from "./badge.module";

View File

@@ -1,5 +1,5 @@
<div
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-pl-4 tw-text-contrast"
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-pl-4 tw-text-contrast 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"
@@ -9,6 +9,7 @@
<ng-content></ng-content>
</span>
<button
*ngIf="showClose"
type="button"
bitIconButton="bwi-close"
buttonType="contrast"

View File

@@ -17,6 +17,7 @@ export class BannerComponent implements OnInit {
@Input("bannerType") bannerType: BannerTypes = "info";
@Input() icon: string;
@Input() useAlertRole = true;
@Input() showClose = true;
@Output() onClose = new EventEmitter<void>();

View File

@@ -35,6 +35,7 @@ export default {
},
args: {
bannerType: "warning",
showClose: true,
},
argTypes: {
onClose: { action: "onClose" },
@@ -50,7 +51,7 @@ export const Premium: Story = {
render: (args: BannerComponent) => ({
props: args,
template: `
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)">
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)" [showClose]=showClose>
Content Really Long Text Lorem Ipsum Ipsum Ipsum
<button bitLink linkType="contrast">Button</button>
</bit-banner>
@@ -82,3 +83,43 @@ export const Danger: Story = {
bannerType: "danger",
},
};
export const HideClose: Story = {
...Premium,
args: {
showClose: false,
},
};
export const Stacked: Story = {
args: {},
render: (args: BannerComponent) => ({
props: args,
template: `
<bit-banner bannerType="premium" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="premium" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="warning" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="warning" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="danger" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="danger" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="info" (onClose)="onClose($event)">
Bruce
</bit-banner>
<bit-banner bannerType="info" (onClose)="onClose($event)">
Bruce
</bit-banner>
`,
}),
};

View File

@@ -31,7 +31,7 @@ export default {
applicationConfig({
providers: [
importProvidersFrom(
RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true })
RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true }),
),
],
}),

View File

@@ -17,6 +17,8 @@ describe("Button", () => {
declarations: [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();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
@@ -45,13 +47,13 @@ describe("Button", () => {
fixture.detectChanges();
expect(
Array.from(buttonDebugElement.nativeElement.classList).some((klass: string) =>
klass.startsWith("tw-bg")
)
klass.startsWith("tw-bg"),
),
).toBe(false);
expect(
Array.from(linkDebugElement.nativeElement.classList).some((klass: string) =>
klass.startsWith("tw-bg")
)
klass.startsWith("tw-bg"),
),
).toBe(false);
testAppComponent.buttonType = null;

View File

@@ -25,6 +25,7 @@ export class CheckboxComponent implements BitFormControlAbstraction {
"tw-w-3.5",
"tw-mr-1.5",
"tw-bottom-[-1px]", // Fix checkbox looking off-center
"tw-flex-none", // Flexbox fix for bit-form-control
"before:tw-content-['']",
"before:tw-block",
@@ -76,10 +77,12 @@ export class CheckboxComponent implements BitFormControlAbstraction {
constructor(@Optional() @Self() private ngControl?: NgControl) {}
@HostBinding("style.--mask-image")
protected maskImage = `url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E')`;
protected maskImage =
`url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E')`;
@HostBinding("style.--indeterminate-mask-image")
protected indeterminateImage = `url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`;
protected indeterminateImage =
`url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`;
@HostBinding()
@Input()

View File

@@ -1,5 +1,12 @@
import { Component, Input } from "@angular/core";
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import {
FormsModule,
ReactiveFormsModule,
FormBuilder,
Validators,
FormGroup,
FormControl,
} from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service";
@@ -15,7 +22,8 @@ const template = `
<input type="checkbox" bitCheckbox formControlName="checkbox">
<bit-label>Click me</bit-label>
</bit-form-control>
</form>`;
</form>
`;
@Component({
selector: "app-example",
@@ -89,6 +97,40 @@ export const Default: Story = {
},
};
export const Hint: Story = {
render: (args) => ({
props: {
formObj: new FormGroup({
checkbox: new FormControl(false),
}),
},
template: `
<form [formGroup]="formObj">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkbox">
<bit-label>Really long value that never ends.</bit-label>
<bit-hint>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum.
Ut non odio est. Duis eu nisi ultrices, porttitor lorem eget, ornare libero. Fusce ex ante, consequat ac
sem et, euismod placerat tellus.
</bit-hint>
</bit-form-control>
</form>
`,
}),
parameters: {
docs: {
source: {
code: template,
},
},
},
args: {
checked: false,
disabled: false,
},
};
export const Custom: Story = {
render: (args) => ({
props: args,

View File

@@ -0,0 +1,3 @@
<div class="tw-max-w-4xl">
<ng-content></ng-content>
</div>

View File

@@ -0,0 +1,13 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
/**
* Generic container that constrains page content width.
*/
@Component({
selector: "bit-container",
templateUrl: "container.component.html",
imports: [CommonModule],
standalone: true,
})
export class ContainerComponent {}

View File

@@ -0,0 +1 @@
export * from "./container.component";

View File

@@ -6,6 +6,7 @@ import { AsyncActionsModule } from "../async-actions";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
import { DialogComponent } from "./dialog/dialog.component";
import { DialogService } from "./dialog.service";
@@ -22,6 +23,7 @@ import { IconDirective, SimpleDialogComponent } from "./simple-dialog/simple-dia
CdkDialogModule,
IconButtonModule,
ReactiveFormsModule,
TypographyModule,
],
declarations: [
DialogCloseDirective,

View File

@@ -35,8 +35,7 @@ class StoryDialogComponent {
@Component({
template: `
<bit-dialog dialogSize="large">
<span bitDialogTitle>Dialog Title</span>
<bit-dialog title="Dialog Title" dialogSize="large">
<span bitDialogContent>
Dialog body text goes here.
<br />
@@ -50,7 +49,10 @@ class StoryDialogComponent {
`,
})
class StoryDialogContentComponent {
constructor(public dialogRef: DialogRef, @Inject(DIALOG_DATA) private data: Animal) {}
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
get animal() {
return this.data?.animal;

View File

@@ -44,7 +44,7 @@ export class DialogService extends Dialog implements OnDestroy {
@Optional() router: Router,
@Optional() authService: AuthService,
protected i18nService: I18nService
protected i18nService: I18nService,
) {
super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy);
@@ -55,7 +55,7 @@ export class DialogService extends Dialog implements OnDestroy {
filter((event) => event instanceof NavigationEnd),
switchMap(() => authService.getAuthStatus()),
filter((v) => v !== AuthenticationStatus.Unlocked),
takeUntil(this._destroy$)
takeUntil(this._destroy$),
)
.subscribe(() => this.closeAll());
}
@@ -69,7 +69,7 @@ export class DialogService extends Dialog implements OnDestroy {
override open<R = unknown, D = unknown, C = unknown>(
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
config?: DialogConfig<D, DialogRef<R, C>>
config?: DialogConfig<D, DialogRef<R, C>>,
): DialogRef<R, C> {
config = {
backdropClass: this.backDropClasses,
@@ -86,10 +86,7 @@ export class DialogService extends Dialog implements OnDestroy {
* @returns `boolean` - True if the user accepted the dialog, false otherwise.
*/
async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise<boolean> {
const dialogRef = this.open<boolean>(SimpleConfigurableDialogComponent, {
data: simpleDialogOptions,
disableClose: simpleDialogOptions.disableClose,
});
const dialogRef = this.openSimpleDialogRef(simpleDialogOptions);
return firstValueFrom(dialogRef.closed);
}
@@ -97,16 +94,15 @@ export class DialogService extends Dialog implements OnDestroy {
/**
* Opens a simple dialog.
*
* @deprecated Use `openSimpleDialog` instead. If you find a use case for the `dialogRef`
* please let #wg-component-library know and we can un-deprecate this method.
* You should probably use `openSimpleDialog` instead, unless you need to programmatically close the dialog.
*
* @param {SimpleDialogOptions} simpleDialogOptions - An object containing options for the dialog.
* @returns `DialogRef` - The reference to the opened dialog.
* Contains a closed observable which can be subscribed to for determining which button
* a user pressed
*/
openSimpleDialogRef(simpleDialogOptions: SimpleDialogOptions): DialogRef {
return this.open(SimpleConfigurableDialogComponent, {
openSimpleDialogRef(simpleDialogOptions: SimpleDialogOptions): DialogRef<boolean> {
return this.open<boolean, SimpleDialogOptions>(SimpleConfigurableDialogComponent, {
data: simpleDialogOptions,
disableClose: simpleDialogOptions.disableClose,
});

View File

@@ -1,12 +1,16 @@
<div
<section
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
[ngClass]="width"
@fadeIn
>
<div
class="tw-flex tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
<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"
>
<h1 bitDialogTitleContainer class="tw-mb-0 tw-grow tw-truncate tw-text-lg">
<h1 bitDialogTitleContainer bitTypography="h3" noMargin class="tw-mb-0 tw-truncate">
{{ title }}
<span *ngIf="subtitle" class="tw-text-muted tw-font-normal tw-text-sm">
{{ subtitle }}
</span>
<ng-content select="[bitDialogTitle]"></ng-content>
</h1>
<button
@@ -18,7 +22,7 @@
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
</div>
</header>
<div class="tw-relative tw-flex tw-flex-col tw-overflow-hidden">
<div
@@ -39,9 +43,9 @@
</div>
</div>
<div
<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-alt tw-p-4"
>
<ng-content select="[bitDialogFooter]"></ng-content>
</div>
</div>
</footer>
</section>

View File

@@ -14,6 +14,16 @@ export class DialogComponent {
*/
@Input() dialogSize: "small" | "default" | "large" = "default";
/**
* Title to show in the dialog's header
*/
@Input() title: string;
/**
* Subtitle to show in the dialog's header
*/
@Input() subtitle: string;
private _disablePadding = false;
/**
* Disable the built-in padding on the dialog, for use with tabbed dialogs.
@@ -32,7 +42,7 @@ export class DialogComponent {
@HostBinding("class") get classes() {
return ["tw-flex", "tw-flex-col", "tw-max-h-screen", "tw-w-screen", "tw-p-4"].concat(
this.width
this.width,
);
}

View File

@@ -1,3 +1,4 @@
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -7,8 +8,7 @@ import { IconButtonModule } from "../../icon-button";
import { SharedModule } from "../../shared";
import { TabsModule } from "../../tabs";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { DialogCloseDirective } from "../directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
import { DialogModule } from "../dialog.module";
import { DialogComponent } from "./dialog.component";
@@ -17,8 +17,14 @@ export default {
component: DialogComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, SharedModule, IconButtonModule, TabsModule],
declarations: [DialogTitleContainerDirective, DialogCloseDirective],
imports: [
DialogModule,
ButtonModule,
SharedModule,
IconButtonModule,
TabsModule,
NoopAnimationsModule,
],
providers: [
{
provide: I18nService,
@@ -56,8 +62,7 @@ export const Default: Story = {
render: (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
<span bitDialogTitle>{{title}}</span>
<bit-dialog [dialogSize]="dialogSize" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding">
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" [disabled]="loading">Save</button>
@@ -77,6 +82,7 @@ export const Default: Story = {
args: {
dialogSize: "default",
title: "Default",
subtitle: "Subtitle",
},
};
@@ -117,8 +123,7 @@ export const ScrollingContent: Story = {
render: (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
<span bitDialogTitle>Scrolling Example</span>
<bit-dialog title="Scrolling Example" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
<span bitDialogContent>
Dialog body text goes here.<br>
<ng-container *ngFor="let _ of [].constructor(100)">
@@ -142,8 +147,7 @@ export const TabContent: Story = {
render: (args) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>Tab Content Example</span>
<bit-dialog title="Tab Content Example" [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogContent>
<bit-tab-group>
<bit-tab label="First Tab">First Tab Content</bit-tab>

View File

@@ -18,11 +18,13 @@ export class DialogTitleContainerDirective implements OnInit {
// Based on angular/components, licensed under MIT
// https://github.com/angular/components/blob/14.2.0/src/material/dialog/dialog-content-directives.ts#L121-L128
if (this.dialogRef) {
// 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
Promise.resolve().then(() => {
const container = this.dialogRef.containerInstance as CdkDialogContainer;
if (container && !container._ariaLabelledBy) {
container._ariaLabelledBy = this.id;
if (container && container._ariaLabelledByQueue.length === 0) {
container._ariaLabelledByQueue.push(this.id);
}
});
}

View File

@@ -44,7 +44,7 @@ export class SimpleConfigurableDialogComponent {
constructor(
public dialogRef: DialogRef,
private i18nService: I18nService,
@Inject(DIALOG_DATA) public simpleDialogOpts?: SimpleDialogOptions
@Inject(DIALOG_DATA) public simpleDialogOpts?: SimpleDialogOptions,
) {
this.localizeText();
}
@@ -66,7 +66,7 @@ export class SimpleConfigurableDialogComponent {
// If accept text is overridden, use cancel, otherwise no
this.cancelButtonText = this.translate(
this.simpleDialogOpts.cancelButtonText,
this.simpleDialogOpts.acceptButtonText !== undefined ? "cancel" : "no"
this.simpleDialogOpts.acceptButtonText !== undefined ? "cancel" : "no",
);
}
}

View File

@@ -122,7 +122,10 @@ class StoryDialogComponent {
calloutType = "info";
dialogCloseResult: boolean;
constructor(public dialogService: DialogService, private i18nService: I18nService) {}
constructor(
public dialogService: DialogService,
private i18nService: I18nService,
) {}
async openSimpleConfigurableDialog(opts: SimpleDialogOptions) {
this.dialogCloseResult = await this.dialogService.openSimpleDialog(opts);

View File

@@ -50,7 +50,10 @@ class StoryDialogComponent {
`,
})
class StoryDialogContentComponent {
constructor(public dialogRef: DialogRef, @Inject(DIALOG_DATA) private data: Animal) {}
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
get animal() {
return this.data?.animal;

View File

@@ -1,13 +1,13 @@
<label [class]="labelClasses">
<ng-content></ng-content>
<span [class]="labelContentClasses">
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
<span>
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
</span>
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
</span>
</label>
<div>
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
</div>
<div *ngIf="hasError" class="tw-mt-1 tw-text-danger">
<i class="bwi bwi-error"></i> {{ displayError }}
</div>

View File

@@ -20,23 +20,37 @@ export class FormControlComponent {
this._inline = coerceBooleanProperty(value);
}
private _disableMargin = false;
@Input() set disableMargin(value: boolean | "") {
this._disableMargin = coerceBooleanProperty(value);
}
get disableMargin() {
return this._disableMargin;
}
@ContentChild(BitFormControlAbstraction) protected formControl: BitFormControlAbstraction;
@HostBinding("class") get classes() {
return ["tw-mb-6"].concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"]);
return []
.concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"])
.concat(this.disableMargin ? [] : ["tw-mb-6"]);
}
constructor(private i18nService: I18nService) {}
protected get labelClasses() {
return ["tw-transition", "tw-select-none", "tw-mb-0"].concat(
this.formControl.disabled ? "tw-cursor-auto" : "tw-cursor-pointer"
);
return [
"tw-transition",
"tw-select-none",
"tw-mb-0",
"tw-inline-flex",
"tw-items-baseline",
].concat(this.formControl.disabled ? "tw-cursor-auto" : "tw-cursor-pointer");
}
protected get labelContentClasses() {
return ["tw-font-semibold"].concat(
this.formControl.disabled ? "tw-text-muted" : "tw-text-main"
return ["tw-inline-flex", "tw-flex-col", "tw-font-semibold"].concat(
this.formControl.disabled ? "tw-text-muted" : "tw-text-main",
);
}

View File

@@ -6,7 +6,7 @@ let nextId = 0;
@Directive({
selector: "bit-hint",
host: {
class: "tw-text-muted tw-inline-block tw-mt-1",
class: "tw-text-muted tw-font-normal tw-inline-block tw-mt-1",
},
})
export class BitHintComponent {

View File

@@ -4,7 +4,7 @@ import { AbstractControl, UntypedFormGroup } from "@angular/forms";
@Component({
selector: "bit-error-summary",
template: ` <ng-container *ngIf="errorCount > 0">
<i class="bwi bwi-error"></i> {{ "fieldsNeedAttention" | i18n : errorString }}
<i class="bwi bwi-error"></i> {{ "fieldsNeedAttention" | i18n: errorString }}
</ng-container>`,
host: {
class: "tw-block tw-text-danger tw-mt-2",

View File

@@ -44,7 +44,7 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
constructor(
@Host() private button: BitIconButtonComponent,
private formField: BitFormFieldComponent,
private i18nService: I18nService
private i18nService: I18nService,
) {}
get icon() {

View File

@@ -1 +1,2 @@
export * from "./search";
export * from "./no-access";

View File

@@ -0,0 +1,12 @@
import { svgIcon } from "../icon";
export const NoAccess = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="154" height="130" fill="none">
<path class="tw-stroke-secondary-500" d="M60.795 112.1h55.135a4 4 0 0 0 4-4V59.65M32.9 51.766V6a4 4 0 0 1 4-4h79.03a4 4 0 0 1 4 4v19.992" stroke-width="4"/>
<path class="tw-stroke-secondary-500" d="M46.997 21.222h13.806M69.832 21.222h13.806M93.546 21.222h13.806M46.997 44.188h13.806M69.832 44.188h13.806M93.546 44.188h13.806M50.05 67.02h10.753M69.832 67.02h13.806M93.546 67.02h13.806M46.997 90.118h13.806M69.832 90.118h13.806M93.546 90.118h13.806" stroke-width="2" stroke-linecap="round"/>
<path class="tw-stroke-secondary-500" d="M30.914 89.366c10.477 0 18.97-8.493 18.97-18.97 0-10.476-8.493-18.97-18.97-18.97-10.476 0-18.969 8.494-18.969 18.97 0 10.477 8.493 18.97 18.97 18.97ZM2.313 117.279c2.183-16.217 15.44-27.362 29.623-27.362 14.07 0 25.942 11.022 27.898 27.33.167 1.39-.988 2.753-2.719 2.753H5c-1.741 0-2.87-1.366-2.687-2.721Z" stroke-width="4"/>
<path class="tw-stroke-danger-500" d="m147.884 50.361-15.89-27.522c-2.31-4-8.083-4-10.392 0l-15.891 27.523c-2.309 4 .578 9 5.196 9h31.781c4.619 0 7.505-5 5.196-9Z" stroke-width="4"/>
<path class="tw-stroke-danger-500" d="M126.798 29.406v16.066" stroke-width="4" stroke-linecap="round"/>
<path class="tw-fill-danger-500" d="M126.798 54.727a2.635 2.635 0 1 0 0-5.27 2.635 2.635 0 0 0 0 5.27Z" />
</svg>
`;

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@@ -5,9 +5,11 @@ export * from "./badge";
export * from "./banner";
export * from "./breadcrumbs";
export * from "./button";
export { ButtonType } from "./shared/button-like.abstraction";
export * from "./callout";
export * from "./checkbox";
export * from "./color-password";
export * from "./container";
export * from "./dialog";
export * from "./form-field";
export * from "./i18n";
@@ -20,9 +22,11 @@ export * from "./menu";
export * from "./multi-select";
export * from "./navigation";
export * from "./no-items";
export * from "./popover";
export * from "./progress";
export * from "./radio-button";
export * from "./search";
export * from "./section";
export * from "./select";
export * from "./table";
export * from "./tabs";

View File

@@ -0,0 +1,54 @@
import { Directive, ElementRef, Input, NgZone, Optional } from "@angular/core";
import { take } from "rxjs/operators";
import { Utils } from "@bitwarden/common/platform/misc/utils";
/**
* Interface for implementing focusable components. Used by the AutofocusDirective.
*/
export abstract class FocusableElement {
focus: () => void;
}
/**
* Directive to focus an element.
*
* @remarks
*
* If the component provides the `FocusableElement` interface, the `focus`
* method will be called. Otherwise, the native element will be focused.
*/
@Directive({
selector: "[appAutofocus], [bitAutofocus]",
})
export class AutofocusDirective {
@Input() set appAutofocus(condition: boolean | string) {
this.autofocus = condition === "" || condition === true;
}
private autofocus: boolean;
constructor(
private el: ElementRef,
private ngZone: NgZone,
@Optional() private focusableElement: FocusableElement,
) {}
ngOnInit() {
if (!Utils.isMobileBrowser && this.autofocus) {
if (this.ngZone.isStable) {
this.focus();
} else {
this.ngZone.onStable.pipe(take(1)).subscribe(this.focus.bind(this));
}
}
}
private focus() {
if (this.focusableElement) {
this.focusableElement.focus();
} else {
this.el.nativeElement.focus();
}
}
}

View File

@@ -1 +1,2 @@
export * from "./input.module";
export * from "./autofocus.directive";

View File

@@ -104,7 +104,7 @@ export class BitInputDirective implements BitFormFieldControl {
constructor(
@Optional() @Self() private ngControl: NgControl,
private ngZone: NgZone,
private elementRef: ElementRef<HTMLInputElement>
private elementRef: ElementRef<HTMLInputElement>,
) {}
focus() {

View File

@@ -1,10 +1,36 @@
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
<div class="tw-flex tw-w-full">
<aside
class="tw-fixed tw-inset-y-0 tw-left-0 tw-h-screen tw-w-60 tw-overflow-auto tw-bg-background-alt3"
[ngStyle]="
variant === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)'
}
"
class="tw-sticky tw-inset-y-0 tw-h-screen tw-w-60 tw-overflow-auto tw-bg-background-alt3"
>
<ng-content select="[slot=sidebar]"></ng-content>
</aside>
<main class="tw-ml-60 tw-min-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6">
<main
[id]="mainContentId"
tabindex="-1"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6"
>
<ng-content></ng-content>
</main>
</div>

View File

@@ -1,9 +1,23 @@
import { Component } from "@angular/core";
import { Component, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { LinkModule } from "../link";
import { SharedModule } from "../shared";
export type LayoutVariant = "primary" | "secondary";
@Component({
selector: "bit-layout",
templateUrl: "layout.component.html",
standalone: true,
imports: [],
imports: [SharedModule, LinkModule, RouterModule],
})
export class LayoutComponent {}
export class LayoutComponent {
protected mainContentId = "main-content";
@Input() variant: LayoutVariant = "primary";
focusMainContent() {
document.getElementById(this.mainContentId)?.focus();
}
}

View File

@@ -1,5 +1,6 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
import { userEvent } from "@storybook/testing-library";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -18,9 +19,10 @@ export default {
* 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) => /* HTML */ `<div class="tw-scale-100 tw-border-2 tw-border-solid tw-border-[red]">
${story}
</div>`
(story) =>
/* HTML */ `<div class="tw-scale-100 tw-border-2 tw-border-solid tw-border-[red]">
${story}
</div>`,
),
moduleMetadata({
imports: [NavigationModule, RouterTestingModule, CalloutModule],
@@ -28,7 +30,11 @@ export default {
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({});
return new I18nMockService({
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
});
},
},
],
@@ -56,6 +62,165 @@ export const WithContent: Story = {
<bit-nav-divider></bit-nav-divider>
<bit-nav-item text="Item C" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Item D" icon="bwi-collection"></bit-nav-item>
<bit-nav-group text="Tree example" icon="bwi-collection" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</nav>
<bit-callout title="Foobar"> Hello world! </bit-callout>
</bit-layout>
`,
}),
};
export const SkipLinks: Story = {
...WithContent,
play: async () => {
await userEvent.tab();
},
};
export const Secondary: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<bit-layout variant="secondary">
<nav slot="sidebar">
<bit-nav-item text="Item A" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Item B" icon="bwi-collection"></bit-nav-item>
<bit-nav-divider></bit-nav-divider>
<bit-nav-item text="Item C" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Item D" icon="bwi-collection"></bit-nav-item>
<bit-nav-group text="Tree example" icon="bwi-collection" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</nav>
<bit-callout title="Foobar"> Hello world! </bit-callout>
</bit-layout>

View File

@@ -1,6 +1,6 @@
import { Input, HostBinding, Directive } from "@angular/core";
export type LinkType = "primary" | "secondary" | "contrast";
export type LinkType = "primary" | "secondary" | "contrast" | "light";
const linkStyles: Record<LinkType, string[]> = {
primary: [
@@ -21,6 +21,12 @@ const linkStyles: Record<LinkType, string[]> = {
"focus-visible:before:tw-ring-text-contrast",
"disabled:!tw-text-contrast/60",
],
light: [
"!tw-text-alt2",
"hover:!tw-text-alt2",
"focus-visible:before:tw-ring-text-alt2",
"disabled:!tw-text-alt2/60",
],
};
const commonStyles = [

View File

@@ -59,7 +59,7 @@ export class MenuTriggerForDirective implements OnDestroy {
constructor(
private elementRef: ElementRef<HTMLElement>,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay
private overlay: Overlay,
) {}
@HostListener("click") toggleMenu() {
@@ -110,7 +110,7 @@ export class MenuTriggerForDirective implements OnDestroy {
filter((event: KeyboardEvent) => {
const keys = this.menu.ariaRole === "menu" ? ["Escape", "Tab"] : ["Escape"];
return keys.includes(event.key);
})
}),
);
const backdrop = this.overlayRef.backdropClick();
const menuClosed = this.menu.closed;

View File

@@ -1,7 +1,7 @@
<ng-template>
<div
(click)="closed.emit()"
class="tw-flex tw-shrink-0 tw-flex-col tw-rounded tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-2"
class="tw-flex tw-shrink-0 tw-flex-col tw-rounded tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-2 tw-overflow-y-auto"
[attr.role]="ariaRole"
[attr.aria-label]="ariaLabel"
cdkTrapFocus

View File

@@ -22,6 +22,8 @@ describe("Menu", () => {
declarations: [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();
fixture = TestBed.createComponent(TestApp);

View File

@@ -26,7 +26,7 @@
<button
type="button"
bitBadge
badgeType="primary"
variant="primary"
class="tw-mr-1 disabled:tw-border-0"
[disabled]="disabled"
(click)="clear(item)"

View File

@@ -56,7 +56,10 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
@Output() onItemsConfirmed = new EventEmitter<any[]>();
constructor(private i18nService: I18nService, @Optional() @Self() private ngControl?: NgControl) {
constructor(
private i18nService: I18nService,
@Optional() @Self() private ngControl?: NgControl,
) {
if (ngControl != null) {
ngControl.valueAccessor = this;
}

View File

@@ -1,4 +1,5 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
/**
* Base class used in `NavGroupComponent` and `NavItemComponent`
@@ -25,6 +26,11 @@ export abstract class NavBaseComponent {
*/
@Input() route: string | any[];
/**
* Passed to internal `routerLink`
*/
@Input() relativeTo?: ActivatedRoute | null;
/**
* If this item is used within a tree, set `variant` to `"tree"`
*/

View File

@@ -3,12 +3,14 @@
[text]="text"
[icon]="icon"
[route]="route"
[relativeTo]="relativeTo"
[variant]="variant"
(mainContentClicked)="toggle()"
[treeDepth]="treeDepth"
(mainContentClicked)="mainContentClicked.emit()"
[ariaLabel]="ariaLabel"
[exactMatch]="exactMatch"
[hideActiveStyles]="parentHideActiveStyles"
>
<ng-template #button>
<button
@@ -17,7 +19,7 @@
[bitIconButton]="
open ? 'bwi-angle-up' : variant === 'tree' ? 'bwi-angle-right' : 'bwi-angle-down'
"
[buttonType]="'main'"
[buttonType]="'light'"
(click)="toggle($event)"
size="small"
[title]="'toggleCollapse' | i18n"
@@ -32,8 +34,11 @@
<ng-container slot="start" *ngIf="variant === 'tree'">
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
<ng-container slot="end" *ngIf="variant !== 'tree'">
<ng-container *ngTemplateOutlet="button"></ng-container>
<ng-container slot="end">
<ng-content select="[slot=end]"></ng-content>
<ng-container *ngIf="variant !== 'tree'">
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
</ng-container>
</bit-nav-item>

View File

@@ -3,29 +3,30 @@ import {
Component,
ContentChildren,
EventEmitter,
forwardRef,
Input,
Optional,
Output,
QueryList,
SkipSelf,
} from "@angular/core";
import { NavBaseComponent } from "./nav-base.component";
import { NavItemComponent } from "./nav-item.component";
@Component({
selector: "bit-nav-group",
templateUrl: "./nav-group.component.html",
providers: [{ provide: NavBaseComponent, useExisting: NavGroupComponent }],
})
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
@ContentChildren(forwardRef(() => NavGroupComponent), {
@ContentChildren(NavBaseComponent, {
descendants: true,
})
nestedGroups!: QueryList<NavGroupComponent>;
nestedNavComponents!: QueryList<NavBaseComponent>;
@ContentChildren(NavItemComponent, {
descendants: true,
})
nestedItems!: QueryList<NavItemComponent>;
/** The parent nav item should not show active styles when open. */
protected get parentHideActiveStyles(): boolean {
return this.hideActiveStyles || this.open;
}
/**
* UID for `[attr.aria-controls]`
@@ -46,10 +47,19 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
@Output()
openChange = new EventEmitter<boolean>();
constructor(@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent) {
super();
}
setOpen(isOpen: boolean) {
this.open = isOpen;
this.openChange.emit(this.open);
this.open && this.parentNavGroup?.setOpen(this.open);
}
protected toggle(event?: MouseEvent) {
event?.stopPropagation();
this.open = !this.open;
this.openChange.emit(this.open);
this.setOpen(!this.open);
}
/**
@@ -59,7 +69,7 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
if (this.variant !== "tree") {
return;
}
[...this.nestedGroups, ...this.nestedItems].forEach((navGroupOrItem) => {
[...this.nestedNavComponents].forEach((navGroupOrItem) => {
navGroupOrItem.treeDepth += 1;
});
}

View File

@@ -1,5 +1,6 @@
import { RouterTestingModule } from "@angular/router/testing";
import { StoryObj, Meta, moduleMetadata } from "@storybook/angular";
import { Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -9,12 +10,18 @@ import { I18nMockService } from "../utils/i18n-mock.service";
import { NavGroupComponent } from "./nav-group.component";
import { NavigationModule } from "./navigation.module";
@Component({
standalone: true,
template: "",
})
class DummyContentComponent {}
export default {
title: "Component Library/Nav/Nav Group",
component: NavGroupComponent,
decorators: [
moduleMetadata({
imports: [SharedModule, RouterTestingModule, NavigationModule],
imports: [SharedModule, RouterModule, NavigationModule, DummyContentComponent],
providers: [
{
provide: I18nService,
@@ -27,6 +34,19 @@ export default {
},
],
}),
applicationConfig({
providers: [
importProvidersFrom(
RouterModule.forRoot(
[
{ path: "", redirectTo: "a", pathMatch: "full" },
{ path: "**", component: DummyContentComponent },
],
{ useHash: true },
),
),
],
}),
],
parameters: {
design: {
@@ -40,10 +60,10 @@ export const Default: StoryObj<NavGroupComponent> = {
render: (args) => ({
props: args,
template: `
<bit-nav-group text="Hello World (Anchor)" [route]="['']" icon="bwi-filter" [open]="true">
<bit-nav-item text="Child A" route="#" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B" route="#"></bit-nav-item>
<bit-nav-item text="Child C" route="#" icon="bwi-filter"></bit-nav-item>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B" route="b"></bit-nav-item>
<bit-nav-item text="Child C" route="c" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
@@ -59,19 +79,19 @@ export const Tree: StoryObj<NavGroupComponent> = {
props: args,
template: `
<bit-nav-group text="Tree example" icon="bwi-collection" [open]="true">
<bit-nav-group text="Level 1 - with children (empty)" route="#" icon="bwi-collection" variant="tree"></bit-nav-group>
<bit-nav-item text="Level 1 - no children" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 1 - with children" route="#" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-group text="Level 2 - with children" route="#" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-item text="Level 3 - no children, no icon" route="#" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 3 - with children" route="#" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-item text="Level 4 - no children, no icon" route="#" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 1 - with children (empty)" route="t1" icon="bwi-collection" variant="tree"></bit-nav-group>
<bit-nav-item text="Level 1 - no children" route="t2" icon="bwi-collection" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 1 - with children" route="t3" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-group text="Level 2 - with children" route="t4" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-item text="Level 3 - no children, no icon" route="t5" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 3 - with children" route="t6" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-item text="Level 4 - no children, no icon" route="t7" variant="tree"></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group text="Level 2 - with children (empty)" route="#" icon="bwi-collection" variant="tree" [open]="true"></bit-nav-group>
<bit-nav-item text="Level 2 - no children" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 2 - with children (empty)" route="t8" icon="bwi-collection" variant="tree" [open]="true"></bit-nav-group>
<bit-nav-item text="Level 2 - no children" route="t9" icon="bwi-collection" variant="tree"></bit-nav-item>
</bit-nav-group>
<bit-nav-item text="Level 1 - no children" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
<bit-nav-item text="Level 1 - no children" route="t10" icon="bwi-collection" variant="tree"></bit-nav-item>
</bit-nav-group>
`,
}),

View File

@@ -1,7 +1,7 @@
<div
class="tw-relative"
[ngClass]="[
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3',
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
fvwStyles$ | async
]"
>
@@ -39,7 +39,9 @@
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<i class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"></i
><span [ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'">{{ text }}</span>
><span [title]="text" [ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'">{{
text
}}</span>
</ng-template>
<!-- Show if a value was passed to `this.to` -->
@@ -49,11 +51,12 @@
<a
class="fvw tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
[routerLink]="route"
[relativeTo]="relativeTo"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="rlaOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setActive($event)"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
@@ -73,7 +76,7 @@
</ng-template>
<div
class="tw-flex tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
class="tw-flex tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
>
<ng-content select="[slot=end]"></ng-content>
</div>

View File

@@ -1,23 +1,28 @@
import { Component, HostListener, Input } from "@angular/core";
import { Component, HostListener, Input, Optional } from "@angular/core";
import { IsActiveMatchOptions } from "@angular/router";
import { BehaviorSubject, map } from "rxjs";
import { NavBaseComponent } from "./nav-base.component";
import { NavGroupComponent } from "./nav-group.component";
@Component({
selector: "bit-nav-item",
templateUrl: "./nav-item.component.html",
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
})
export class NavItemComponent extends NavBaseComponent {
/**
* Is `true` if `to` matches the current route
*/
private _active = false;
protected setActive(isActive: boolean) {
this._active = isActive;
private _isActive = false;
protected setIsActive(isActive: boolean) {
this._isActive = isActive;
if (this._isActive && this.parentNavGroup) {
this.parentNavGroup.setOpen(true);
}
}
protected get showActiveStyles() {
return this._active && !this.hideActiveStyles;
return this._isActive && !this.hideActiveStyles;
}
protected rlaOptions: IsActiveMatchOptions = {
paths: "subset",
@@ -42,7 +47,9 @@ export class NavItemComponent extends NavBaseComponent {
*/
protected focusVisibleWithin$ = new BehaviorSubject(false);
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
map((value) => (value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-text-alt2" : ""))
map((value) =>
value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-text-alt2" : "",
),
);
@HostListener("focusin", ["$event.target"])
onFocusIn(target: HTMLElement) {
@@ -52,4 +59,8 @@ export class NavItemComponent extends NavBaseComponent {
onFocusOut() {
this.focusVisibleWithin$.next(false);
}
constructor(@Optional() private parentNavGroup: NavGroupComponent) {
super();
}
}

View File

@@ -64,7 +64,7 @@ export const WithChildButtons: Story = {
slot="start"
class="tw-ml-auto"
[bitIconButton]="'bwi-clone'"
[buttonType]="'contrast'"
[buttonType]="'light'"
size="small"
aria-label="option 1"
></button>
@@ -72,7 +72,7 @@ export const WithChildButtons: Story = {
slot="end"
class="tw-ml-auto"
[bitIconButton]="'bwi-pencil-square'"
[buttonType]="'contrast'"
[buttonType]="'light'"
size="small"
aria-label="option 2"
></button>
@@ -80,7 +80,7 @@ export const WithChildButtons: Story = {
slot="end"
class="tw-ml-auto"
[bitIconButton]="'bwi-check'"
[buttonType]="'contrast'"
[buttonType]="'light'"
size="small"
aria-label="option 3"
></button>

View File

@@ -0,0 +1,150 @@
import { ConnectedPosition } from "@angular/cdk/overlay";
const ORIGIN_OFFSET_PX = 6;
const OVERLAY_OFFSET_PX = 24;
export type PositionIdentifier =
| "right-start"
| "right-center"
| "right-end"
| "left-start"
| "left-center"
| "left-end"
| "below-start"
| "below-center"
| "below-end"
| "above-start"
| "above-center"
| "above-end";
export interface DefaultPosition extends ConnectedPosition {
id: PositionIdentifier;
}
export const defaultPositions: DefaultPosition[] = [
/**
* The order of these positions matters. The Popover component will use
* the first position that fits within the viewport.
*/
// Popover opens to right of trigger
{
id: "right-start",
offsetX: ORIGIN_OFFSET_PX,
offsetY: -OVERLAY_OFFSET_PX,
originX: "end",
originY: "center",
overlayX: "start",
overlayY: "top",
panelClass: ["bit-popover-right", "bit-popover-right-start"],
},
{
id: "right-center",
offsetX: ORIGIN_OFFSET_PX,
originX: "end",
originY: "center",
overlayX: "start",
overlayY: "center",
panelClass: ["bit-popover-right", "bit-popover-right-center"],
},
{
id: "right-end",
offsetX: ORIGIN_OFFSET_PX,
offsetY: OVERLAY_OFFSET_PX,
originX: "end",
originY: "center",
overlayX: "start",
overlayY: "bottom",
panelClass: ["bit-popover-right", "bit-popover-right-end"],
},
// ... to left of trigger
{
id: "left-start",
offsetX: -ORIGIN_OFFSET_PX,
offsetY: -OVERLAY_OFFSET_PX,
originX: "start",
originY: "center",
overlayX: "end",
overlayY: "top",
panelClass: ["bit-popover-left", "bit-popover-left-start"],
},
{
id: "left-center",
offsetX: -ORIGIN_OFFSET_PX,
originX: "start",
originY: "center",
overlayX: "end",
overlayY: "center",
panelClass: ["bit-popover-left", "bit-popover-left-center"],
},
{
id: "left-end",
offsetX: -ORIGIN_OFFSET_PX,
offsetY: OVERLAY_OFFSET_PX,
originX: "start",
originY: "center",
overlayX: "end",
overlayY: "bottom",
panelClass: ["bit-popover-left", "bit-popover-left-end"],
},
// ... below trigger
{
id: "below-center",
offsetY: ORIGIN_OFFSET_PX,
originX: "center",
originY: "bottom",
overlayX: "center",
overlayY: "top",
panelClass: ["bit-popover-below", "bit-popover-below-center"],
},
{
id: "below-start",
offsetX: -OVERLAY_OFFSET_PX,
offsetY: ORIGIN_OFFSET_PX,
originX: "center",
originY: "bottom",
overlayX: "start",
overlayY: "top",
panelClass: ["bit-popover-below", "bit-popover-below-start"],
},
{
id: "below-end",
offsetX: OVERLAY_OFFSET_PX,
offsetY: ORIGIN_OFFSET_PX,
originX: "center",
originY: "bottom",
overlayX: "end",
overlayY: "top",
panelClass: ["bit-popover-below", "bit-popover-below-end"],
},
// ... above trigger
{
id: "above-center",
offsetY: -ORIGIN_OFFSET_PX,
originX: "center",
originY: "top",
overlayX: "center",
overlayY: "bottom",
panelClass: ["bit-popover-above", "bit-popover-above-center"],
},
{
id: "above-start",
offsetX: -OVERLAY_OFFSET_PX,
offsetY: -ORIGIN_OFFSET_PX,
originX: "center",
originY: "top",
overlayX: "start",
overlayY: "bottom",
panelClass: ["bit-popover-above", "bit-popover-above-start"],
},
{
id: "above-end",
offsetX: OVERLAY_OFFSET_PX,
offsetY: -ORIGIN_OFFSET_PX,
originX: "center",
originY: "top",
overlayX: "end",
overlayY: "bottom",
panelClass: ["bit-popover-above", "bit-popover-above-end"],
},
];

View File

@@ -0,0 +1 @@
export * from "./popover.module";

View File

@@ -0,0 +1,131 @@
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
import { TemplatePortal } from "@angular/cdk/portal";
import {
AfterViewInit,
Directive,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy,
ViewContainerRef,
} from "@angular/core";
import { Observable, Subscription, filter, mergeWith } from "rxjs";
import { defaultPositions } from "./default-positions";
import { PopoverComponent } from "./popover.component";
@Directive({
selector: "[bitPopoverTriggerFor]",
standalone: true,
exportAs: "popoverTrigger",
})
export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
@Input()
@HostBinding("attr.aria-expanded")
popoverOpen = false;
@Input("bitPopoverTriggerFor")
popover: PopoverComponent;
@Input("position")
position: string;
private overlayRef: OverlayRef;
private closedEventsSub: Subscription;
get positions() {
if (!this.position) {
return defaultPositions;
}
const preferredPosition = defaultPositions.find((position) => position.id === this.position);
if (preferredPosition) {
return [preferredPosition, ...defaultPositions];
}
return defaultPositions;
}
get defaultPopoverConfig(): OverlayConfig {
return {
hasBackdrop: true,
backdropClass: "cdk-overlay-transparent-backdrop",
scrollStrategy: this.overlay.scrollStrategies.reposition(),
positionStrategy: this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions(this.positions)
.withLockedPosition(true)
.withFlexibleDimensions(false)
.withPush(true),
};
}
constructor(
private elementRef: ElementRef<HTMLElement>,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay,
) {}
@HostListener("click")
togglePopover() {
if (this.popoverOpen) {
this.closePopover();
} else {
this.openPopover();
}
}
private openPopover() {
this.popoverOpen = true;
this.overlayRef = this.overlay.create(this.defaultPopoverConfig);
const templatePortal = new TemplatePortal(this.popover.templateRef, this.viewContainerRef);
this.overlayRef.attach(templatePortal);
this.closedEventsSub = this.getClosedEvents().subscribe(() => {
this.destroyPopover();
});
}
private getClosedEvents(): Observable<any> {
const detachments = this.overlayRef.detachments();
const escKey = this.overlayRef
.keydownEvents()
.pipe(filter((event: KeyboardEvent) => event.key === "Escape"));
const backdrop = this.overlayRef.backdropClick();
const popoverClosed = this.popover.closed;
return detachments.pipe(mergeWith(escKey, backdrop, popoverClosed));
}
private destroyPopover() {
if (this.overlayRef == null || !this.popoverOpen) {
return;
}
this.popoverOpen = false;
this.disposeAll();
}
private disposeAll() {
this.closedEventsSub?.unsubscribe();
this.overlayRef?.dispose();
}
ngAfterViewInit() {
if (this.popoverOpen) {
this.openPopover();
}
}
ngOnDestroy() {
this.disposeAll();
}
closePopover() {
this.destroyPopover();
}
}

View File

@@ -0,0 +1,49 @@
.bit-popover-arrow {
@apply tw-absolute tw-z-10 tw-h-4 tw-w-4 tw-rotate-45 tw-border-solid tw-bg-background;
}
.bit-popover-right .bit-popover-arrow {
@apply tw-left-1 -tw-translate-x-1/2 tw-rounded-bl-sm tw-border-b tw-border-l tw-border-b-secondary-300 tw-border-l-secondary-300;
}
.bit-popover-left .bit-popover-arrow {
@apply tw-right-1 tw-translate-x-1/2 tw-rounded-tr-sm tw-border-r tw-border-t tw-border-r-secondary-300 tw-border-t-secondary-300;
}
.bit-popover-right-start .bit-popover-arrow,
.bit-popover-left-start .bit-popover-arrow {
@apply tw-top-6 -tw-translate-y-1/2;
}
.bit-popover-right-center .bit-popover-arrow,
.bit-popover-left-center .bit-popover-arrow {
@apply tw-top-1/2 -tw-translate-y-1/2;
}
.bit-popover-right-end .bit-popover-arrow,
.bit-popover-left-end .bit-popover-arrow {
@apply tw-bottom-6 tw-translate-y-1/2;
}
.bit-popover-below .bit-popover-arrow {
@apply tw-top-1 -tw-translate-y-1/2 tw-rounded-tl-sm tw-border-l tw-border-t tw-border-l-secondary-300 tw-border-t-secondary-300;
}
.bit-popover-above .bit-popover-arrow {
@apply tw-bottom-1 tw-translate-y-1/2 tw-rounded-br-sm tw-border-b tw-border-r tw-border-b-secondary-300 tw-border-r-secondary-300;
}
.bit-popover-below-start .bit-popover-arrow,
.bit-popover-above-start .bit-popover-arrow {
@apply tw-left-6 -tw-translate-x-1/2;
}
.bit-popover-below-center .bit-popover-arrow,
.bit-popover-above-center .bit-popover-arrow {
@apply tw-left-1/2 -tw-translate-x-1/2;
}
.bit-popover-below-end .bit-popover-arrow,
.bit-popover-above-end .bit-popover-arrow {
@apply tw-right-6 tw-translate-x-1/2;
}

View File

@@ -0,0 +1,26 @@
<ng-template>
<section cdkTrapFocus cdkTrapFocusAutoCapture class="tw-relative" role="dialog" aria-modal="true">
<div class="tw-overflow-hidden tw-rounded-md tw-border tw-border-solid tw-border-secondary-300">
<div
class="tw-relative tw-z-20 tw-w-72 tw-break-words tw-bg-background tw-pb-4 tw-pt-2 tw-text-main"
>
<div class="tw-mb-1 tw-mr-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-pl-4">
<h2 class="tw-mb-0 tw-mt-1 tw-text-base tw-font-semibold">
{{ title }}
</h2>
<button
type="button"
bitIconButton="bwi-close"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
(click)="closed.emit()"
></button>
</div>
<div class="tw-px-4">
<ng-content></ng-content>
</div>
</div>
</div>
<div class="bit-popover-arrow"></div>
</section>
</ng-template>

View File

@@ -0,0 +1,18 @@
import { A11yModule } from "@angular/cdk/a11y";
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
import { IconButtonModule } from "../icon-button/icon-button.module";
import { SharedModule } from "../shared/shared.module";
@Component({
standalone: true,
selector: "bit-popover",
imports: [A11yModule, IconButtonModule, SharedModule],
templateUrl: "./popover.component.html",
exportAs: "popoverComponent",
})
export class PopoverComponent {
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@Input() title = "";
@Output() closed = new EventEmitter();
}

View File

@@ -0,0 +1,88 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./popover.stories";
<Meta of={stories} />
# Popover
A popover is a page overlay that is triggered by a selecting a button. It displays interactive
content.
Popovers remain actively open until a user dismisses it in one of the following ways:
- Presses the Esc key
- Presses the close "x" button in the Popover
- Presses a button within the Popover triggering close
- Clicks outside of the Popover
Popovers are used to provide the user with additional context about an interaction or page. We
primarily use popovers when a user clicks on an icon-button with a question icon. This launches a
popover that provides the user with in app help text.
Note: Popovers are not tooltips. Use tooltips to show a short text to respondents when they hover
over a word or icon. Use popovers to show a longer text, or when you want to link to an external web
page.
<Primary />
## Open on Page Load
A Popover can be set to initially open on page load by setting `[popoverOpen]="true"` on the trigger
element, like so:
```html
<button [bitPopoverTriggerFor]="myPopover" [popoverOpen]="true">Open Popover</button>
```
## Positions
The Popover component uses the following list of default "positions" to determine where to position
the Popover overlay.
1. right-start ---> "Open the Popover to the RIGHT of the trigger and align the START of the Popover
with the trigger"
2. right-center
3. right-end
4. left-start
5. left-center
6. left-end
7. below-start
8. below-center
9. below-end
10. above-start
11. above-center
12. above-end
The order here matters. If position 1 fits within the viewport, it will be used. If it does not, the
Popover component will try position 2, and so forth. This cascading behavior ensures that if the
user resizes the screen, the Popover component will find the best way to reposition itself.
### Example
Suppose you have a trigger element on the right side of the page. The `right-start` position will
not work because there is not enough space to open the Popover to the right. The same is true for
`right-center` and `right-end`.
The first position that "fits" is `left-start`, and therefore that is where the Popover will open.
<Story of={stories.LeftStart} />
### Manually Setting a Position
You can manually set the initial position of the Popover by binding a `[position]` input on the
Popover's trigger element, such as:
```html
<button [bitPopoverTriggerFor]="myPopover" [position]="'above-end'">Open Popover</button>
```
<Story of={stories.AboveEnd} />
Note that if the user resizes the page and the Popover no longer fits in the viewport, the Popover
component will fall back to the list of default positions to find the best position.
To test this out, open the Popopver in the example above and then slowly resize your browser window
horizontally to make it smaller. When the Popover no longer fits the `above-end` position, it will
jump down below the trigger, using `below-center`, because that is the first position that fits
based on the list of default positions.

View File

@@ -0,0 +1,10 @@
import { NgModule } from "@angular/core";
import { PopoverTriggerForDirective } from "./popover-trigger-for.directive";
import { PopoverComponent } from "./popover.component";
@NgModule({
imports: [PopoverComponent, PopoverTriggerForDirective],
exports: [PopoverComponent, PopoverTriggerForDirective],
})
export class PopoverModule {}

View File

@@ -0,0 +1,411 @@
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 { PopoverTriggerForDirective } from "./popover-trigger-for.directive";
import { PopoverModule } from "./popover.module";
export default {
title: "Component Library/Popover",
decorators: [
moduleMetadata({
imports: [PopoverModule, ButtonModule, IconButtonModule, SharedModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
close: "Close",
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1717-15868",
},
},
argTypes: {
position: {
options: [
"right-start",
"right-center",
"right-end",
"left-start",
"left-center",
"left-end",
"below-start",
"below-center",
"below-end",
"above-start",
"above-center",
"above-end",
],
control: { type: "select" },
},
},
args: {
position: "right-start",
},
} as Meta;
type Story = StoryObj<PopoverTriggerForDirective>;
const popoverContent = `
<bit-popover [title]="'Example Title'" #myPopover>
<div>Lorem ipsum dolor <a href="#">adipisicing elit</a>.</div>
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
<li>Dolor sit amet consectetur</li>
<li>Esse labore veniam tempora</li>
<li>Adipisicing elit ipsum <a href="#">iustolaborum</a></li>
</ul>
<button bitButton class="tw-mt-3" (click)="triggerRef.closePopover()">Close</button>
</bit-popover>
`;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const Open: Story = {
render: (args) => ({
props: args,
template: `
<bit-popover [title]="'Example Title'" #myPopover="popoverComponent">
<div>Lorem ipsum dolor <a href="#">adipisicing elit</a>.</div>
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
<li>Dolor sit amet consectetur</li>
<li>Esse labore veniam tempora</li>
<li>Adipisicing elit ipsum <a href="#">iustolaborum</a></li>
</ul>
</bit-popover>
<div class="tw-h-40">
<div class="cdk-overlay-pane bit-popover-right bit-popover-right-start">
<ng-container *ngTemplateOutlet="myPopover.templateRef"></ng-container>
</div>
</div>
`,
}),
};
export const InitiallyOpen: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
[popoverOpen]="true"
#triggerRef="popoverTrigger"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const RightStart: Story = {
args: {
position: "right-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const RightCenter: Story = {
args: {
position: "right-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const RightEnd: Story = {
args: {
position: "right-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const LeftStart: Story = {
args: {
position: "left-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const LeftCenter: Story = {
args: {
position: "left-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const LeftEnd: Story = {
args: {
position: "left-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const BelowStart: Story = {
args: {
position: "below-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const BelowCenter: Story = {
args: {
position: "below-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const BelowEnd: Story = {
args: {
position: "below-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const AboveStart: Story = {
args: {
position: "above-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const AboveCenter: Story = {
args: {
position: "above-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const AboveEnd: Story = {
args: {
position: "above-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};

View File

@@ -33,7 +33,7 @@ export class ProgressComponent {
get outerBarStyles() {
return ["tw-overflow-hidden", "tw-rounded", "tw-bg-secondary-100"].concat(
SizeClasses[this.size]
SizeClasses[this.size],
);
}

View File

@@ -1,15 +1,15 @@
<bit-form-control inline>
<bit-form-control [inline]="!block" disableMargin>
<input
type="radio"
bitRadio
[id]="inputId"
[name]="name"
[disabled]="groupDisabled || disabled"
[value]="value"
[checked]="selected"
(change)="onInputChange()"
(blur)="onBlur()"
/>
<ng-content select="bit-label" ngProjectAs="bit-label"></ng-content>
<ng-content select="bit-hint" ngProjectAs="bit-hint"></ng-content>
</bit-form-control>

View File

@@ -27,6 +27,8 @@ describe("RadioButton", () => {
],
});
// 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();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();

View File

@@ -10,6 +10,10 @@ let nextId = 0;
})
export class RadioButtonComponent {
@HostBinding("attr.id") @Input() id = `bit-radio-button-${nextId++}`;
@HostBinding("class") get classList() {
return [this.block ? "tw-block" : "tw-inline-block", "tw-mb-2"];
}
@Input() value: unknown;
@Input() disabled = false;
@@ -31,6 +35,10 @@ export class RadioButtonComponent {
return this.groupComponent.disabled;
}
get block() {
return this.groupComponent.block;
}
protected onInputChange() {
this.groupComponent.onInputChange(this.value);
}

View File

@@ -2,13 +2,14 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormControlModule } from "../form-control";
import { SharedModule } from "../shared";
import { RadioButtonComponent } from "./radio-button.component";
import { RadioGroupComponent } from "./radio-group.component";
import { RadioInputComponent } from "./radio-input.component";
@NgModule({
imports: [CommonModule, FormControlModule],
imports: [CommonModule, SharedModule, FormControlModule],
declarations: [RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
exports: [FormControlModule, RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
})

View File

@@ -1,5 +1,5 @@
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup } from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -45,19 +45,19 @@ export const Inline: Story = {
radio: new FormControl(0),
}),
},
template: `
template: /* HTML */ `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
</bit-radio-button>
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
</bit-radio-button>
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
</bit-radio-button>
@@ -67,6 +67,37 @@ export const Inline: Story = {
}),
};
export const InlineHint: Story = {
render: () => ({
props: {
formObj: new FormGroup({
radio: new FormControl(0),
}),
},
template: /* HTML */ `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
</bit-radio-button>
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
</bit-radio-button>
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
</bit-radio-button>
<bit-hint>This is a hint for the radio group</bit-hint>
</bit-radio-group>
</form>
`,
}),
};
export const Block: Story = {
render: () => ({
props: {
@@ -74,22 +105,23 @@ export const Block: Story = {
radio: new FormControl(0),
}),
},
template: `
template: /* HTML */ `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-radio-group formControlName="radio" aria-label="Example radio group" [block]="true">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" class="tw-block" [value]="0">
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
<bit-hint>This is a hint for the first option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-second" class="tw-block" [value]="1">
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
<bit-hint>This is a hint for the second option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-third" class="tw-block" [value]="2">
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
<bit-hint>This is a hint for the third option</bit-hint>
</bit-radio-button>
@@ -98,3 +130,37 @@ export const Block: Story = {
`,
}),
};
export const BlockHint: Story = {
render: () => ({
props: {
formObj: new FormGroup({
radio: new FormControl(0),
}),
},
template: /* HTML */ `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group" [block]="true">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
<bit-hint>This is a hint for the first option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
<bit-hint>This is a hint for the second option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
<bit-hint>This is a hint for the third option</bit-hint>
</bit-radio-button>
<bit-hint>This is a hint for the radio group</bit-hint>
</bit-radio-group>
</form>
`,
}),
};

View File

@@ -1,7 +1,8 @@
<ng-container *ngIf="label">
<fieldset>
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-semibold tw-text-main">
<legend class="tw-mb-1 tw-block tw-text-base tw-font-semibold tw-text-main">
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
</legend>
<ng-container *ngTemplateOutlet="content"></ng-container>
</fieldset>
@@ -11,4 +12,9 @@
<ng-container *ngTemplateOutlet="content"></ng-container>
</ng-container>
<ng-template #content><ng-content></ng-content></ng-template>
<ng-template #content>
<div>
<ng-content></ng-content>
</div>
<ng-content select="bit-hint" ngProjectAs="bit-hint"></ng-content>
</ng-template>

View File

@@ -23,6 +23,8 @@ describe("RadioGroupComponent", () => {
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
});
// 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();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();

View File

@@ -1,5 +1,5 @@
import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core";
import { ControlValueAccessor, NgControl } from "@angular/forms";
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
import { BitLabel } from "../form-control/label.directive";
@@ -21,8 +21,11 @@ export class RadioGroupComponent implements ControlValueAccessor {
this._name = value;
}
@Input() block = false;
@HostBinding("attr.role") role = "radiogroup";
@HostBinding("attr.id") @Input() id = `bit-radio-group-${nextId++}`;
@HostBinding("class") classList = ["tw-block", "tw-mb-4"];
@ContentChild(BitLabel) protected label: BitLabel;
@@ -32,6 +35,10 @@ export class RadioGroupComponent implements ControlValueAccessor {
}
}
get required() {
return this.ngControl?.control?.hasValidator(Validators.required) ?? false;
}
// ControlValueAccessor
onChange: (value: unknown) => void;
onTouched: () => void;

View File

@@ -29,6 +29,7 @@ export class RadioInputComponent implements BitFormControlAbstraction {
"tw-h-3.5",
"tw-mr-1.5",
"tw-bottom-[-1px]", // Fix checkbox looking off-center
"tw-flex-none", // Flexbox fix for bit-form-control
"hover:tw-border-2",
"[&>label:hover]:tw-border-2",

View File

@@ -0,0 +1,16 @@
/**
* Reset styles to be consistent with Bootstrap reset
* Reassess when Bootstrap is removed and Tailwind preflight is added
*/
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}

View File

@@ -8,6 +8,7 @@
<i class="bwi bwi-search bwi-fw tw-text-muted"></i>
</label>
<input
#input
bitInput
type="search"
[id]="id"

View File

@@ -1,6 +1,8 @@
import { Component, Input } from "@angular/core";
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { FocusableElement } from "../input/autofocus.directive";
let nextId = 0;
@Component({
@@ -12,18 +14,28 @@ let nextId = 0;
multi: true,
useExisting: SearchComponent,
},
{
provide: FocusableElement,
useExisting: SearchComponent,
},
],
})
export class SearchComponent implements ControlValueAccessor {
export class SearchComponent implements ControlValueAccessor, FocusableElement {
private notifyOnChange: (v: string) => void;
private notifyOnTouch: () => void;
@ViewChild("input") private input: ElementRef<HTMLInputElement>;
protected id = `search-id-${nextId++}`;
protected searchText: string;
@Input() disabled: boolean;
@Input() placeholder: string;
focus() {
this.input.nativeElement.focus();
}
onChange(searchText: string) {
if (this.notifyOnChange != undefined) {
this.notifyOnChange(searchText);

View File

@@ -0,0 +1 @@
export * from "./section.component";

View File

@@ -0,0 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
@Component({
selector: "bit-section",
standalone: true,
imports: [CommonModule],
template: `
<section class="tw-mb-12">
<ng-content></ng-content>
</section>
`,
})
export class SectionComponent {}

View File

@@ -0,0 +1,35 @@
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
import { TypographyModule } from "../typography";
import { SectionComponent } from "./section.component";
export default {
title: "Component Library/Section",
component: SectionComponent,
decorators: [
moduleMetadata({
imports: [TypographyModule],
}),
componentWrapperDecorator((story) => `<div class="tw-text-main">${story}</div>`),
],
} as Meta;
type Story = StoryObj<SectionComponent>;
/** Sections are simple containers that apply a bottom margin. They often contain a heading. */
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<bit-section>
<h2 bitTypography="h2">Foo</h2>
<p bitTypography="body1">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras vitae congue risus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nunc elementum odio nibh, eget pellentesque sem ornare vitae. Etiam vel ante et velit fringilla egestas a sed sem. Fusce molestie nisl et nisi accumsan dapibus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed eu risus ex. </p>
</bit-section>
<bit-section>
<h2 bitTypography="h2">Bar</h2>
<p bitTypography="body1">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras vitae congue risus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nunc elementum odio nibh, eget pellentesque sem ornare vitae. Etiam vel ante et velit fringilla egestas a sed sem. Fusce molestie nisl et nisi accumsan dapibus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed eu risus ex. </p>
</bit-section>
`,
}),
};

View File

@@ -39,7 +39,10 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
private notifyOnChange?: (value: T) => void;
private notifyOnTouched?: () => void;
constructor(private i18nService: I18nService, @Optional() @Self() private ngControl?: NgControl) {
constructor(
private i18nService: I18nService,
@Optional() @Self() private ngControl?: NgControl,
) {
if (ngControl != null) {
ngControl.valueAccessor = this;
}

View File

@@ -44,49 +44,50 @@ or an options menu icon.
## Actions
| Icon | bwi-name | Usage |
| ------------------------------------- | ----------------- | -------------------------------------------- |
| <i class="bwi bwi-check-circle"></i> | bwi-check-circle | check if password has been exposed |
| <i class="bwi bwi-check-square"></i> | bwi-check-square | select all action |
| <i class="bwi bwi-clone"></i> | bwi-clone | copy to clipboard action |
| <i class="bwi bwi-close"></i> | bwi-close | close action |
| <i class="bwi bwi-cog"></i> | bwi-cog | settings |
| <i class="bwi bwi-cog-f"></i> | bwi-cog-f | settings |
| <i class="bwi bwi-cogs"></i> | bwi-cogs | deprecated; do not use in app. |
| <i class="bwi bwi-download"></i> | bwi-download | download or export |
| <i class="bwi bwi-envelope"></i> | bwi-envelope | action related to emailing a user |
| <i class="bwi bwi-external-link"></i> | bwi-external-link | open in new window or popout |
| <i class="bwi bwi-eye"></i> | bwi-eye | show icon for password fields |
| <i class="bwi bwi-eye-slash"></i> | bwi-eye-slash | hide icon for password fields |
| <i class="bwi bwi-files"></i> | bwi-files | clone action / duplicate an item |
| <i class="bwi bwi-generate"></i> | bwi-generate | generate action in edit item forms |
| <i class="bwi bwi-generate-f"></i> | bwi-generate-f | generate feature or action |
| <i class="bwi bwi-lock"></i> | bwi-lock | lock vault action |
| <i class="bwi bwi-lock-f"></i> | bwi-lock-f | - |
| <i class="bwi bwi-minus-circle"></i> | bwi-minus-circle | remove action |
| <i class="bwi bwi-minus-square"></i> | bwi-minus-square | unselect all action |
| <i class="bwi bwi-paste"></i> | bwi-paste | paste from clipboard action |
| <i class="bwi bwi-pencil-square"></i> | bwi-pencil-square | edit action |
| <i class="bwi bwi-play"></i> | bwi-play | start or play action |
| <i class="bwi bwi-plus"></i> | bwi-plus | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-circle"></i> | bwi-plus-circle | new or add option in text buttons/links |
| <i class="bwi bwi-plus-square"></i> | bwi-plus-square | - |
| <i class="bwi bwi-refresh"></i> | bwi-refresh | "re"-action; such as refresh or regenerate |
| <i class="bwi bwi-refresh-tab"></i> | bwi-refresh-tab | - |
| <i class="bwi bwi-save"></i> | bwi-save | alternate download action |
| <i class="bwi bwi-save-changes"></i> | bwi-save-changes | save changes action |
| <i class="bwi bwi-search"></i> | bwi-search | search action |
| <i class="bwi bwi-share"></i> | bwi-share | - |
| <i class="bwi bwi-share-arrow"></i> | bwi-share-arrow | - |
| <i class="bwi bwi-share-square"></i> | bwi-share-square | avoid using; use external-link instead |
| <i class="bwi bwi-sign-in"></i> | bwi-sign-in | sign-in action |
| <i class="bwi bwi-sign-out"></i> | bwi-sign-out | sign-out action |
| <i class="bwi bwi-star"></i> | bwi-star | favorite action |
| <i class="bwi bwi-star-f"></i> | bwi-star-f | favorited / unfavorite action |
| <i class="bwi bwi-stop"></i> | bwi-stop | stop action |
| <i class="bwi bwi-trash"></i> | bwi-trash | delete action or trash area |
| <i class="bwi bwi-undo"></i> | bwi-undo | restore action |
| <i class="bwi bwi-unlock"></i> | bwi-unlock | unlocked |
| Icon | bwi-name | Usage |
| -------------------------------------- | ------------------ | -------------------------------------------- |
| <i class="bwi bwi-check-circle"></i> | bwi-check-circle | check if password has been exposed |
| <i class="bwi bwi-check-square"></i> | bwi-check-square | select all action |
| <i class="bwi bwi-clone"></i> | bwi-clone | copy to clipboard action |
| <i class="bwi bwi-close"></i> | bwi-close | close action |
| <i class="bwi bwi-cog"></i> | bwi-cog | settings |
| <i class="bwi bwi-cog-f"></i> | bwi-cog-f | settings |
| <i class="bwi bwi-cogs"></i> | bwi-cogs | deprecated; do not use in app. |
| <i class="bwi bwi-download"></i> | bwi-download | download or export |
| <i class="bwi bwi-envelope"></i> | bwi-envelope | action related to emailing a user |
| <i class="bwi bwi-external-link"></i> | bwi-external-link | open in new window or popout |
| <i class="bwi bwi-eye"></i> | bwi-eye | show icon for password fields |
| <i class="bwi bwi-eye-slash"></i> | bwi-eye-slash | hide icon for password fields |
| <i class="bwi bwi-files"></i> | bwi-files | clone action / duplicate an item |
| <i class="bwi bwi-generate"></i> | bwi-generate | generate action in edit item forms |
| <i class="bwi bwi-generate-f"></i> | bwi-generate-f | generate feature or action |
| <i class="bwi bwi-lock"></i> | bwi-lock | lock vault action |
| <i class="bwi bwi-lock-encrypted"></i> | bwi-lock-encrypted | - |
| <i class="bwi bwi-lock-f"></i> | bwi-lock-f | - |
| <i class="bwi bwi-minus-circle"></i> | bwi-minus-circle | remove action |
| <i class="bwi bwi-minus-square"></i> | bwi-minus-square | unselect all action |
| <i class="bwi bwi-paste"></i> | bwi-paste | paste from clipboard action |
| <i class="bwi bwi-pencil-square"></i> | bwi-pencil-square | edit action |
| <i class="bwi bwi-play"></i> | bwi-play | start or play action |
| <i class="bwi bwi-plus"></i> | bwi-plus | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-circle"></i> | bwi-plus-circle | new or add option in text buttons/links |
| <i class="bwi bwi-plus-square"></i> | bwi-plus-square | - |
| <i class="bwi bwi-refresh"></i> | bwi-refresh | "re"-action; such as refresh or regenerate |
| <i class="bwi bwi-refresh-tab"></i> | bwi-refresh-tab | - |
| <i class="bwi bwi-save"></i> | bwi-save | alternate download action |
| <i class="bwi bwi-save-changes"></i> | bwi-save-changes | save changes action |
| <i class="bwi bwi-search"></i> | bwi-search | search action |
| <i class="bwi bwi-share"></i> | bwi-share | - |
| <i class="bwi bwi-share-arrow"></i> | bwi-share-arrow | - |
| <i class="bwi bwi-share-square"></i> | bwi-share-square | avoid using; use external-link instead |
| <i class="bwi bwi-sign-in"></i> | bwi-sign-in | sign-in action |
| <i class="bwi bwi-sign-out"></i> | bwi-sign-out | sign-out action |
| <i class="bwi bwi-star"></i> | bwi-star | favorite action |
| <i class="bwi bwi-star-f"></i> | bwi-star-f | favorited / unfavorite action |
| <i class="bwi bwi-stop"></i> | bwi-stop | stop action |
| <i class="bwi bwi-trash"></i> | bwi-trash | delete action or trash area |
| <i class="bwi bwi-undo"></i> | bwi-undo | restore action |
| <i class="bwi bwi-unlock"></i> | bwi-unlock | unlocked |
## Directional and Menu Indicators
@@ -154,6 +155,7 @@ or an options menu icon.
| <i class="bwi bwi-mobile"></i> | bwi-mobile | mobile client |
| <i class="bwi bwi-money"></i> | bwi-money | - |
| <i class="bwi bwi-paperclip"></i> | bwi-paperclip | attachments |
| <i class="bwi bwi-passkey"></i> | bwi-passkey | passkey |
| <i class="bwi bwi-pencil"></i> | bwi-pencil | editing |
| <i class="bwi bwi-provider"></i> | bwi-provider | relates to provider or provider portal |
| <i class="bwi bwi-providers"></i> | bwi-providers | - |

View File

@@ -104,6 +104,7 @@ component library and the other clients will follow once this work is completed.
className="link-item"
href="https://storybook.js.org/docs/react/get-started/setup#configure-storybook-for-your-stack"
target="_blank"
rel="noreferrer"
>
<span>
<strong>Data</strong>
@@ -115,13 +116,18 @@ component library and the other clients will follow once this work is completed.
<div className="subheading">Learn</div>
<div className="link-list">
<a className="link-item" href="https://storybook.js.org/docs" target="_blank">
<a className="link-item" href="https://storybook.js.org/docs" target="_blank" rel="noreferrer">
<span>
<strong>Storybook documentation</strong>
Configure, customize, and extend
</span>
</a>
<a className="link-item" href="https://storybook.js.org/tutorials/" target="_blank">
<a
className="link-item"
href="https://storybook.js.org/tutorials/"
target="_blank"
rel="noreferrer"
>
<span>
<strong>In-depth guides</strong>
Best practices from leading teams

View File

@@ -0,0 +1,214 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Documentation/Migration" />
# Migrating to the Component Library
You have been tasked with migrating a component to use the CL. What does that entail?
## Getting Started
Before progressing here, please ensure that...
- You have fully setup your dev environment as described in the
[contributing docs](https://contributing.bitwarden.com/).
- You are familiar with [Angular reactive forms](https://angular.io/guide/reactive-forms).
- You are familiar with [Tailwind](https://tailwindcss.com/docs/utility-first).
## Background
The design of Bitwarden is in flux. At the time of writing, the frontend codebase uses a mix of
multiple UI frameworks: Bootstrap, custom "box" styles, and this component library, which is built
on top of Tailwind. In short, the "CL migration" is a move to only use the CL and remove everything
else.
This is very important work. Centralizing around a shared design system will:
- improve user experience by utilizing consistent patterns
- improve developer experience by reducing custom complex UI code
- improve dev & design velocity by having a central location to make UI/UX changes that impact the
entire project
## Success Criteria
Follow these steps to fully migrate a component.
### Use Storybook
Don't recreate the wheel.
After reviewing a design, consult this Storybook to determine if there is a component built for your
usecase. Don't waste effort styling a button or building a popover menu from scratch--we already
have those. If a component isn't flexible enough or doesn't exist for your usecase, contact the
Component Library team.
### Use Tailwind
Only use Tailwind for styling. No Bootstrap or other custom CSS is allowed.
This is easy to verify. Bitwarden prefixes all Tailwind classes with `tw-`. If you see a class
without this prefix, it probably shouldn't be there.
<div class="tw-bg-danger-500/10 tw-p-4">
<span class="tw-font-bold tw-text-danger">Bad (Bootstrap)</span>
```html
<div class="mb-2"></div>
```
</div>
<div class="tw-bg-success-500/10 tw-p-4">
<span class="tw-font-bold tw-text-success">Good (Tailwind)</span>
```html
<div class="tw-mb-2"></div>
```
</div>
**Exception:** Icon font classes, prefixed with `bwi`, are allowed.
<div class="tw-bg-success-500/10 tw-p-4">
<span class="tw-font-bold tw-text-success">Good (Icons)</span>
```html
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
```
</div>
### Use Reactive Forms
The CL has form components that integrate with Angular's reactive forms: `bit-form-field`,
`bitSubmit`, `bit-form-control`, etc. All forms should be migrated from template-drive forms to
reactive forms to make use of these components. Review the
[form component docs](?path=/docs/component-library-form--docs).
<div class="tw-bg-danger-500/10 tw-p-4">
<span class="tw-text-danger tw-font-bold">Bad</span>
```html
<form #form (ngSubmit)="submit()">
...
</form>
```
</div>
<div class="tw-bg-success-500/10 tw-p-4">
<span class="tw-text-success tw-font-bold">Good</span>
```html
<form [formGroup]="formGroup" [bitSubmit]="submit">
...
</form>
```
</div>
### Dialogs
Legacy Bootstrap modals use the `ModalService`. These should be converted to use the `DialogService`
and it's [related CL components](?path=/docs/component-library-dialogs--docs). Components that are
fully migrated should have no reference to the `ModalService`.
1. Update the template to use CL components:
<div class="tw-bg-danger-500/10 tw-p-4">
```html
<!-- FooDialogComponent -->
<div class="modal fade" role="dialog" aria-modal="true">...</div>
```
</div>
<div class="tw-bg-success-500/10 tw-p-4">
```html
<!-- FooDialogComponent -->
<bit-dialog>...</bit-dialog>
```
</div>
2. Create a static `open` method on the component, that calls `DialogService.open`:
<div class="tw-bg-success-500/10 tw-p-4">
```ts
export class FooDialogComponent {
//...
static open(dialogService: DialogService) {
return dialogService.open(DeleteAccountComponent);
}
}
```
</div>
3. If you need to pass data into the dialog, pass it to `open` as a parameter and inject
`DIALOG_DATA` into the component's constructor.
<div class="tw-bg-success-500/10 tw-p-4">
```ts
export type FooDialogParams = {
bar: string;
}
export class FooDialogComponent {
constructor(@Inject(DIALOG_DATA) protected params: FooDialogParams) {}
static open(dialogService: DialogService, data: FooDialogParams) {
return dialogService.open(DeleteAccountComponent, { data });
}
}
```
</div>
4. Replace calls to `ModalService.open` or `ModalService.openViewRef` with the newly created static
`open` method:
<div class="tw-bg-danger-500/10 tw-p-4">`this.modalService.open(FooDialogComponent);`</div>
<div class="tw-bg-success-500/10 tw-p-4">`FooDialogComponent.open(this.dialogService);`</div>
## Examples
The following examples come from accross the Bitwarden codebase.
### 1.) AboutComponent
Codeowner: Platform
https://github.com/bitwarden/clients/pull/6301/files
This migration updates a `ModalService` component to the `DialogService`.
**Note:** Most of the internal markup of this component was unchanged, aside from the removal of
defunct Bootstrap classes.
### 2.) Auth
Codeowner: Auth
https://github.com/bitwarden/clients/pull/5377
This PR also does some general refactoring, the main relevant change can be seen here:
[Old template](https://github.com/bitwarden/clients/pull/5377/files#diff-4fcab9ffa4ed26904c53da3bd130e346986576f2372e90b0f66188c809f9284d)
-->
[New template](https://github.com/bitwarden/clients/pull/5377/files#diff-cb93c74c828b9b49dc7869cc0324f5f7d6609da6f72e38ac6baba6d5b6384327)
Updates a dialog, similar to example 1, but also adds CL form components and Angular Reactive Forms.
### 3.) AC
Codeowner: Admin Console
https://github.com/bitwarden/clients/pull/5417
Migrates dialog, form, buttons, and a table.
### 4.) Vault
Codeowner: Vault
https://github.com/bitwarden/clients/pull/5648
Some of our components are shared between multiple clients (web, desktop, and the browser extension)
through the use of inheritance. This PR updates the _web_ template of a cross-client component to
use Tailwind and the CL, and updates the base component implementation to use reactive forms,
without updating the desktop or browser templates.
## Questions
Please direct any development questions to the Component Library team. Thank you!

View File

@@ -83,11 +83,11 @@ export class TableDataSource<T> extends DataSource<T> {
private updateChangeSubscription() {
const filteredData = combineLatest([this._data, this._filter]).pipe(
map(([data]) => this.filterData(data))
map(([data]) => this.filterData(data)),
);
const orderedData = combineLatest([filteredData, this._sort]).pipe(
map(([data, sort]) => this.orderData(data, sort))
map(([data, sort]) => this.orderData(data, sort)),
);
this._renderChangesSubscription?.unsubscribe();

View File

@@ -39,6 +39,8 @@ export class TableComponent implements OnDestroy, AfterContentChecked {
"tw-w-full",
"tw-leading-normal",
"tw-text-main",
"tw-border-collapse",
"tw-text-start",
this.layout === "auto" ? "tw-table-auto" : "tw-table-fixed",
];
}

View File

@@ -106,6 +106,8 @@ export class TabGroupComponent
// These values need to be updated after change detection as
// the checked content may have references to them.
// 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
Promise.resolve().then(() => {
this.tabs.forEach((tab, index) => (tab.isActive = index === indexToSelect));

View File

@@ -57,8 +57,8 @@ export default {
{ path: "item-3", component: ItemThreeDummyComponent },
{ path: "disabled", component: DisabledDummyComponent },
],
{ useHash: true }
)
{ useHash: true },
),
),
],
}),

View File

@@ -12,7 +12,7 @@ declare const require: {
context(
path: string,
deep?: boolean,
filter?: RegExp
filter?: RegExp,
): {
<T>(id: string): T;
keys(): string[];

View File

@@ -17,6 +17,8 @@ describe("Button", () => {
declarations: [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();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;

View File

@@ -30,23 +30,15 @@ type Story = StoryObj<ToggleGroupComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
template: /* HTML */ `
<bit-toggle-group [(selected)]="selected" aria-label="People list filter">
<bit-toggle value="all">
All <span bitBadge badgeType="info">3</span>
</bit-toggle>
<bit-toggle value="invited">
Invited
</bit-toggle>
<bit-toggle value="accepted">
Accepted <span bitBadge badgeType="info">2</span>
</bit-toggle>
<bit-toggle value="deactivated">
Deactivated
</bit-toggle>
<bit-toggle value="all"> All <span bitBadge variant="info">3</span> </bit-toggle>
<bit-toggle value="invited"> Invited </bit-toggle>
<bit-toggle value="accepted"> Accepted <span bitBadge variant="info">2</span> </bit-toggle>
<bit-toggle value="deactivated"> Deactivated </bit-toggle>
</bit-toggle-group>
`,
}),

View File

@@ -20,6 +20,8 @@ describe("Button", () => {
providers: [{ provide: ToggleGroupComponent, useValue: mockGroupComponent }],
});
// 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();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;

View File

@@ -70,10 +70,8 @@ export class ToggleComponent<TValue> {
// Fix for bootstrap styles that add bottom margin
"!tw-mb-0",
// Fix for badge being pushed slightly lower when inside a button.
// Inspired by bootstrap, which does the same.
"[&>[bitBadge]]:tw-relative",
"[&>[bitBadge]]:tw--top-px",
// Fix for badge being slightly off center vertically
"[&>[bitBadge]]:tw-mt-px",
];
}

View File

@@ -1,3 +1,5 @@
@import "./reset.css";
:root {
--color-transparent-hover: rgb(0 0 0 / 0.03);
@@ -158,6 +160,7 @@
--tw-ring-offset-color: #002b36;
}
@import "./popover/popover.component.css";
@import "./search/search.component.css";
/**
@@ -197,3 +200,9 @@ summary.tw-list-none::-webkit-details-marker {
.cdk-overlay-pane {
z-index: 2000 !important;
}
.cdk-global-scrollblock {
position: relative;
height: 100%;
overflow: hidden;
}