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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./async-actions.module";
|
||||
export * from "./bit-action.directive";
|
||||
export * from "./form-button.directive";
|
||||
export * from "./bit-submit.directive";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { BadgeDirective, BadgeTypes } from "./badge.directive";
|
||||
export { BadgeDirective, BadgeVariant } from "./badge.directive";
|
||||
export * from "./badge.module";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
applicationConfig({
|
||||
providers: [
|
||||
importProvidersFrom(
|
||||
RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true })
|
||||
RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
libs/components/src/container/container.component.html
Normal file
3
libs/components/src/container/container.component.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="tw-max-w-4xl">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
13
libs/components/src/container/container.component.ts
Normal file
13
libs/components/src/container/container.component.ts
Normal 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 {}
|
||||
1
libs/components/src/container/index.ts
Normal file
1
libs/components/src/container/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./container.component";
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./search";
|
||||
export * from "./no-access";
|
||||
|
||||
12
libs/components/src/icon/icons/no-access.ts
Normal file
12
libs/components/src/icon/icons/no-access.ts
Normal 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>
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -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";
|
||||
|
||||
54
libs/components/src/input/autofocus.directive.ts
Normal file
54
libs/components/src/input/autofocus.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./input.module";
|
||||
export * from "./autofocus.directive";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
libs/components/src/popover/default-positions.ts
Normal file
150
libs/components/src/popover/default-positions.ts
Normal 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"],
|
||||
},
|
||||
];
|
||||
1
libs/components/src/popover/index.ts
Normal file
1
libs/components/src/popover/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./popover.module";
|
||||
131
libs/components/src/popover/popover-trigger-for.directive.ts
Normal file
131
libs/components/src/popover/popover-trigger-for.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
49
libs/components/src/popover/popover.component.css
Normal file
49
libs/components/src/popover/popover.component.css
Normal 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;
|
||||
}
|
||||
26
libs/components/src/popover/popover.component.html
Normal file
26
libs/components/src/popover/popover.component.html
Normal 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>
|
||||
18
libs/components/src/popover/popover.component.ts
Normal file
18
libs/components/src/popover/popover.component.ts
Normal 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();
|
||||
}
|
||||
88
libs/components/src/popover/popover.mdx
Normal file
88
libs/components/src/popover/popover.mdx
Normal 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.
|
||||
10
libs/components/src/popover/popover.module.ts
Normal file
10
libs/components/src/popover/popover.module.ts
Normal 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 {}
|
||||
411
libs/components/src/popover/popover.stories.ts
Normal file
411
libs/components/src/popover/popover.stories.ts
Normal 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}
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
libs/components/src/reset.css
Normal file
16
libs/components/src/reset.css
Normal 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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
<i class="bwi bwi-search bwi-fw tw-text-muted"></i>
|
||||
</label>
|
||||
<input
|
||||
#input
|
||||
bitInput
|
||||
type="search"
|
||||
[id]="id"
|
||||
|
||||
@@ -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);
|
||||
|
||||
1
libs/components/src/section/index.ts
Normal file
1
libs/components/src/section/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./section.component";
|
||||
14
libs/components/src/section/section.component.ts
Normal file
14
libs/components/src/section/section.component.ts
Normal 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 {}
|
||||
35
libs/components/src/section/section.stories.ts
Normal file
35
libs/components/src/section/section.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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
|
||||
|
||||
214
libs/components/src/stories/migration.mdx
Normal file
214
libs/components/src/stories/migration.mdx
Normal 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!
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ export default {
|
||||
{ path: "item-3", component: ItemThreeDummyComponent },
|
||||
{ path: "disabled", component: DisabledDummyComponent },
|
||||
],
|
||||
{ useHash: true }
|
||||
)
|
||||
{ useHash: true },
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,7 @@ declare const require: {
|
||||
context(
|
||||
path: string,
|
||||
deep?: boolean,
|
||||
filter?: RegExp
|
||||
filter?: RegExp,
|
||||
): {
|
||||
<T>(id: string): T;
|
||||
keys(): string[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user