1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 17:13:24 +00:00

Merge branch 'feature/org-admin-refresh' into EC-86-groups-table

This commit is contained in:
Shane Melton
2022-09-28 16:00:56 -07:00
573 changed files with 11184 additions and 4053 deletions

View File

@@ -1,5 +1,5 @@
<div
class="tw-flex tw-items-center tw-gap-2 tw-py-2.5 tw-px-4 tw-text-contrast"
class="tw-flex tw-items-center tw-gap-2 tw-py-2.5 tw-px-4 tw-pr-2.5 tw-text-contrast"
[ngClass]="bannerClass"
[attr.role]="useAlertRole ? 'status' : null"
[attr.aria-live]="useAlertRole ? 'polite' : null"
@@ -8,7 +8,12 @@
<span class="tw-grow tw-text-base">
<ng-content></ng-content>
</span>
<button class="tw-border-0 tw-bg-transparent tw-p-0 tw-text-contrast" (click)="onClose.emit()">
<i class="bwi bwi-close tw-text-sm" *ngIf="icon" aria-hidden="true"></i>
</button>
<button
bitIconButton="bwi-close"
buttonType="contrast"
size="default"
(click)="onClose.emit()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
</div>

View File

@@ -1,5 +1,10 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { SharedModule } from "../shared/shared.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BannerComponent } from "./banner.component";
describe("BannerComponent", () => {
@@ -8,7 +13,17 @@ describe("BannerComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharedModule],
declarations: [BannerComponent],
providers: [
{
provide: I18nService,
useFactory: () =>
new I18nMockService({
close: "Close",
}),
},
],
}).compileComponents();
fixture = TestBed.createComponent(BannerComponent);

View File

@@ -1,10 +1,13 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { IconButtonModule } from "../icon-button";
import { SharedModule } from "../shared/shared.module";
import { BannerComponent } from "./banner.component";
@NgModule({
imports: [CommonModule],
imports: [CommonModule, SharedModule, IconButtonModule],
exports: [BannerComponent],
declarations: [BannerComponent],
})

View File

@@ -1,19 +1,43 @@
import { Meta, Story } from "@storybook/angular";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { IconButtonModule } from "../icon-button";
import { SharedModule } from "../shared/shared.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BannerComponent } from "./banner.component";
export default {
title: "Component Library/Banner",
component: BannerComponent,
decorators: [
moduleMetadata({
imports: [SharedModule, IconButtonModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
close: "Close",
});
},
},
],
}),
],
args: {
bannerType: "warning",
},
argTypes: {
onClose: { action: "onClose" },
},
} as Meta;
const Template: Story<BannerComponent> = (args: BannerComponent) => ({
props: args,
template: `
<bit-banner [bannerType]="bannerType">Content Really Long Text Lorem Ipsum Ipsum Ipsum <button>Button</button></bit-banner>
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)">Content Really Long Text Lorem Ipsum Ipsum Ipsum <button>Button</button></bit-banner>
`,
});

View File

@@ -0,0 +1,8 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<ng-content></ng-content>
</span>
<span class="tw-absolute tw-inset-0" [ngClass]="{ 'tw-invisible': !loading }">
<i class="bwi bwi-spinner bwi-lg bwi-spin tw-align-baseline" aria-hidden="true"></i>
</span>
</span>

View File

@@ -8,6 +8,7 @@ describe("Button", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let buttonDebugElement: DebugElement;
let disabledButtonDebugElement: DebugElement;
let linkDebugElement: DebugElement;
beforeEach(waitForAsync(() => {
@@ -20,6 +21,7 @@ describe("Button", () => {
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
buttonDebugElement = fixture.debugElement.query(By.css("button"));
disabledButtonDebugElement = fixture.debugElement.query(By.css("button#disabled"));
linkDebugElement = fixture.debugElement.query(By.css("a"));
}));
@@ -60,16 +62,67 @@ describe("Button", () => {
expect(buttonDebugElement.nativeElement.classList.contains("tw-block")).toBe(false);
expect(linkDebugElement.nativeElement.classList.contains("tw-block")).toBe(false);
});
it("should not be disabled when loading and disabled are false", () => {
testAppComponent.loading = false;
testAppComponent.disabled = false;
fixture.detectChanges();
expect(buttonDebugElement.attributes["loading"]).toBeFalsy();
expect(linkDebugElement.attributes["loading"]).toBeFalsy();
expect(buttonDebugElement.nativeElement.disabled).toBeFalsy();
});
it("should be disabled when disabled is true", () => {
testAppComponent.disabled = true;
fixture.detectChanges();
expect(buttonDebugElement.nativeElement.disabled).toBeTruthy();
// Anchor tags cannot be disabled.
});
it("should be disabled when attribute disabled is true", () => {
expect(disabledButtonDebugElement.nativeElement.disabled).toBeTruthy();
});
it("should be disabled when loading is true", () => {
testAppComponent.loading = true;
fixture.detectChanges();
expect(buttonDebugElement.nativeElement.disabled).toBeTruthy();
});
});
@Component({
selector: "test-app",
template: `
<button type="button" bitButton [buttonType]="buttonType" [block]="block">Button</button>
<a href="#" bitButton [buttonType]="buttonType" [block]="block"> Link </a>
<button
type="button"
bitButton
[buttonType]="buttonType"
[block]="block"
[disabled]="disabled"
[loading]="loading"
>
Button
</button>
<a
href="#"
bitButton
[buttonType]="buttonType"
[block]="block"
[disabled]="disabled"
[loading]="loading"
>
Link
</a>
<button id="disabled" type="button" bitButton disabled>Button</button>
`,
})
class TestApp {
buttonType: string;
block: boolean;
disabled: boolean;
loading: boolean;
}

View File

@@ -0,0 +1,79 @@
import { Input, HostBinding, Component } from "@angular/core";
export type ButtonTypes = "primary" | "secondary" | "danger";
const buttonStyles: Record<ButtonTypes, string[]> = {
primary: [
"tw-border-primary-500",
"tw-bg-primary-500",
"!tw-text-contrast",
"hover:tw-bg-primary-700",
"hover:tw-border-primary-700",
"disabled:tw-bg-primary-500/60",
"disabled:tw-border-primary-500/60",
"disabled:!tw-text-contrast/60",
"disabled:tw-bg-clip-padding",
],
secondary: [
"tw-bg-transparent",
"tw-border-text-muted",
"!tw-text-muted",
"hover:tw-bg-secondary-500",
"hover:tw-border-secondary-500",
"hover:!tw-text-contrast",
"disabled:tw-bg-transparent",
"disabled:tw-border-text-muted/60",
"disabled:!tw-text-muted/60",
],
danger: [
"tw-bg-transparent",
"tw-border-danger-500",
"!tw-text-danger",
"hover:tw-bg-danger-500",
"hover:tw-border-danger-500",
"hover:!tw-text-contrast",
"disabled:tw-bg-transparent",
"disabled:tw-border-danger-500/60",
"disabled:!tw-text-danger/60",
],
};
@Component({
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
})
export class ButtonComponent {
@HostBinding("class") get classList() {
return [
"tw-font-semibold",
"tw-py-1.5",
"tw-px-3",
"tw-rounded",
"tw-transition",
"tw-border",
"tw-border-solid",
"tw-text-center",
"hover:tw-no-underline",
"focus:tw-outline-none",
"focus-visible:tw-ring",
"focus-visible:tw-ring-offset-2",
"focus-visible:tw-ring-primary-700",
"focus-visible:tw-z-10",
]
.concat(
this.block == null || this.block === false ? ["tw-inline-block"] : ["tw-w-full", "tw-block"]
)
.concat(buttonStyles[this.buttonType ?? "secondary"]);
}
@HostBinding("attr.disabled")
get disabledAttr() {
const disabled = this.disabled != null && this.disabled !== false;
return disabled || this.loading ? true : null;
}
@Input() buttonType: ButtonTypes = null;
@Input() block?: boolean;
@Input() loading = false;
@Input() disabled = false;
}

View File

@@ -1,72 +0,0 @@
import { Input, HostBinding, Directive } from "@angular/core";
export type ButtonTypes = "primary" | "secondary" | "danger";
const buttonStyles: Record<ButtonTypes, string[]> = {
primary: [
"tw-border-primary-500",
"tw-bg-primary-500",
"!tw-text-contrast",
"hover:tw-bg-primary-700",
"hover:tw-border-primary-700",
"focus:tw-bg-primary-700",
"focus:tw-border-primary-700",
],
secondary: [
"tw-bg-transparent",
"tw-border-text-muted",
"!tw-text-muted",
"hover:tw-bg-secondary-500",
"hover:tw-border-secondary-500",
"hover:!tw-text-contrast",
"focus:tw-bg-secondary-500",
"focus:tw-border-secondary-500",
"focus:!tw-text-contrast",
],
danger: [
"tw-bg-transparent",
"tw-border-danger-500",
"!tw-text-danger",
"hover:tw-bg-danger-500",
"hover:tw-border-danger-500",
"hover:!tw-text-contrast",
"focus:tw-bg-danger-500",
"focus:tw-border-danger-500",
"focus:!tw-text-contrast",
],
};
@Directive({
selector: "button[bitButton], a[bitButton]",
})
export class ButtonDirective {
@HostBinding("class") get classList() {
return [
"tw-font-semibold",
"tw-py-1.5",
"tw-px-3",
"tw-rounded",
"tw-transition",
"tw-border",
"tw-border-solid",
"tw-text-center",
"hover:tw-no-underline",
"disabled:tw-bg-secondary-100",
"disabled:tw-border-secondary-100",
"disabled:!tw-text-main",
"focus:tw-outline-none",
"focus:tw-ring",
"focus:tw-ring-offset-2",
"focus:tw-ring-primary-700",
"focus:tw-z-10",
]
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
.concat(buttonStyles[this.buttonType ?? "secondary"]);
}
@Input()
buttonType: ButtonTypes = null;
@Input()
block = false;
}

View File

@@ -1,11 +1,11 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { ButtonDirective } from "./button.directive";
import { ButtonComponent } from "./button.component";
@NgModule({
imports: [CommonModule],
exports: [ButtonDirective],
declarations: [ButtonDirective],
exports: [ButtonComponent],
declarations: [ButtonComponent],
})
export class ButtonModule {}

View File

@@ -1,12 +1,14 @@
import { Meta, Story } from "@storybook/angular";
import { ButtonDirective } from "./button.directive";
import { ButtonComponent } from "./button.component";
export default {
title: "Component Library/Button",
component: ButtonDirective,
component: ButtonComponent,
args: {
buttonType: "primary",
disabled: false,
loading: false,
},
parameters: {
design: {
@@ -16,11 +18,11 @@ export default {
},
} as Meta;
const Template: Story<ButtonDirective> = (args: ButtonDirective) => ({
const Template: Story<ButtonComponent> = (args: ButtonComponent) => ({
props: args,
template: `
<button bitButton [buttonType]="buttonType" [block]="block">Button</button>
<a bitButton [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">Link</a>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block">Button</button>
<a bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">Link</a>
`,
});
@@ -39,16 +41,63 @@ Danger.args = {
buttonType: "danger",
};
const DisabledTemplate: Story = (args) => ({
const AllStylesTemplate: Story = (args) => ({
props: args,
template: `
<button bitButton disabled buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton disabled buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton disabled buttonType="danger" class="tw-mr-2">Danger</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
`,
});
export const Disabled = DisabledTemplate.bind({});
Disabled.args = {
size: "small",
export const Loading = AllStylesTemplate.bind({});
Loading.args = {
disabled: false,
loading: true,
};
export const Disabled = AllStylesTemplate.bind({});
Disabled.args = {
disabled: true,
loading: false,
};
const DisabledWithAttributeTemplate: Story = (args) => ({
props: args,
template: `
<ng-container *ngIf="disabled">
<button bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
</ng-container>
<ng-container *ngIf="!disabled">
<button bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
</ng-container>
`,
});
export const DisabledWithAttribute = DisabledWithAttributeTemplate.bind({});
DisabledWithAttribute.args = {
disabled: true,
loading: false,
};
const BlockTemplate: Story<ButtonComponent> = (args: ButtonComponent) => ({
props: args,
template: `
<span class="tw-flex">
<button bitButton [buttonType]="buttonType" [block]="block">[block]="true" Button</button>
<a bitButton [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">[block]="true" Link</a>
<button bitButton [buttonType]="buttonType" block class="tw-ml-2">block Button</button>
<a bitButton [buttonType]="buttonType" block href="#" class="tw-ml-2">block Link</a>
</span>
`,
});
export const Block = BlockTemplate.bind({});
Block.args = {
block: true,
};

View File

@@ -1,2 +1,2 @@
export * from "./button.directive";
export * from "./button.component";
export * from "./button.module";

View File

@@ -1,6 +1,7 @@
import { DialogModule as CdkDialogModule } from "@angular/cdk/dialog";
import { NgModule } from "@angular/core";
import { IconButtonModule } from "../icon-button";
import { SharedModule } from "../shared";
import { DialogService } from "./dialog.service";
@@ -10,11 +11,11 @@ import { DialogTitleContainerDirective } from "./directives/dialog-title-contain
import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component";
@NgModule({
imports: [SharedModule, CdkDialogModule],
imports: [SharedModule, IconButtonModule, CdkDialogModule],
declarations: [
DialogCloseDirective,
DialogComponent,
DialogTitleContainerDirective,
DialogComponent,
SimpleDialogComponent,
],
exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent],

View File

@@ -3,22 +3,23 @@
class="tw-my-4 tw-flex tw-max-h-screen tw-flex-col tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-text-contrast tw-text-main"
>
<div
class="tw-flex tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
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"
>
<h1 bitDialogTitleContainer class="tw-mb-0 tw-grow tw-text-lg tw-uppercase">
<ng-content select="[bitDialogTitle]"></ng-content>
</h1>
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
class="tw-border-0 tw-bg-transparent tw-p-0"
title="{{ 'close' | i18n }}"
attr.aria-label="{{ 'close' | i18n }}"
>
<i class="bwi bwi-close tw-text-xs tw-font-bold tw-text-main" aria-hidden="true"></i>
</button>
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
</div>
<div class="tw-overflow-y-auto tw-p-4 tw-pb-8">
<div class="tw-overflow-y-auto tw-pb-8" [ngClass]="{ 'tw-p-4': !disablePadding }">
<ng-content select="[bitDialogContent]"></ng-content>
</div>

View File

@@ -1,3 +1,4 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, Input } from "@angular/core";
@Component({
@@ -7,6 +8,14 @@ import { Component, Input } from "@angular/core";
export class DialogComponent {
@Input() dialogSize: "small" | "default" | "large" = "default";
private _disablePadding: boolean;
@Input() set disablePadding(value: boolean) {
this._disablePadding = coerceBooleanProperty(value);
}
get disablePadding() {
return this._disablePadding;
}
get width() {
switch (this.dialogSize) {
case "small": {

View File

@@ -3,7 +3,9 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { ButtonModule } from "../../button";
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";
@@ -15,7 +17,7 @@ export default {
component: DialogComponent,
decorators: [
moduleMetadata({
imports: [SharedModule, ButtonModule],
imports: [ButtonModule, SharedModule, IconButtonModule, TabsModule],
declarations: [DialogTitleContainerDirective, DialogCloseDirective],
providers: [
{
@@ -32,6 +34,13 @@ export default {
args: {
dialogSize: "small",
},
argTypes: {
_disablePadding: {
table: {
disable: true,
},
},
},
parameters: {
design: {
type: "figma",
@@ -43,12 +52,19 @@ export default {
const Template: Story<DialogComponent> = (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize">
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>{{title}}</span>
<span bitDialogContent>Dialog body text goes here.</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<div bitDialogFooter class="tw-flex tw-items-center tw-flex-row tw-gap-2">
<button bitButton buttonType="primary">Save</button>
<button bitButton buttonType="secondary">Cancel</button>
<button
class="tw-ml-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
title="Delete"
aria-label="Delete"></button>
</div>
</bit-dialog>
`,
@@ -75,7 +91,7 @@ Large.args = {
const TemplateScrolling: Story<DialogComponent> = (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize">
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>Scrolling Example</span>
<span bitDialogContent>
Dialog body text goes here.<br>
@@ -96,3 +112,37 @@ export const ScrollingContent = TemplateScrolling.bind({});
ScrollingContent.args = {
dialogSize: "small",
};
const TemplateTabbed: Story<DialogComponent> = (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>Tab Content Example</span>
<span bitDialogContent>
<bit-tab-group>
<bit-tab label="First Tab">First Tab Content</bit-tab>
<bit-tab label="Second Tab">Second Tab Content</bit-tab>
<bit-tab label="Third Tab">Third Tab Content</bit-tab>
</bit-tab-group>
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton buttonType="primary">Save</button>
<button bitButton buttonType="secondary">Cancel</button>
</div>
</bit-dialog>
`,
});
export const TabContent = TemplateTabbed.bind({});
TabContent.args = {
dialogSize: "large",
disablePadding: true,
};
TabContent.story = {
parameters: {
docs: {
storyDescription: `An example of using the \`bitTabGroup\` component within the Dialog. The content padding should be
disabled (via \`disablePadding\`) so that the tabs are flush against the dialog title.`,
},
},
};

View File

@@ -2,7 +2,12 @@ import { DialogModule, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/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 { DialogService } from "./dialog.service";
import { DialogCloseDirective } from "./directives/dialog-close.directive";
@@ -60,13 +65,23 @@ export default {
decorators: [
moduleMetadata({
declarations: [
DialogCloseDirective,
SimpleDialogComponent,
DialogTitleContainerDirective,
StoryDialogContentComponent,
DialogCloseDirective,
DialogTitleContainerDirective,
SimpleDialogComponent,
],
imports: [SharedModule, IconButtonModule, ButtonModule, DialogModule],
providers: [
DialogService,
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
close: "Close",
});
},
},
],
imports: [ButtonModule, DialogModule],
providers: [DialogService],
}),
],
parameters: {

View File

@@ -33,6 +33,10 @@ export class BitErrorSummary {
return acc;
}
if (!control.dirty && control.untouched) {
return acc;
}
return acc + Object.keys(control.errors).length;
}, 0);
}

View File

@@ -28,6 +28,8 @@ export class BitErrorComponent {
return this.i18nService.t("inputEmail");
case "minlength":
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
case "maxlength":
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength);
default:
// Attempt to show a custom error message.
if (this.error[1]?.message) {

View File

@@ -0,0 +1,120 @@
import { Component, HostBinding, Input } from "@angular/core";
export type IconButtonStyle = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger";
const styles: Record<IconButtonStyle, string[]> = {
contrast: [
"tw-bg-transparent",
"!tw-text-contrast",
"tw-border-transparent",
"hover:tw-bg-transparent-hover",
"hover:tw-border-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"disabled:hover:tw-bg-transparent",
],
main: [
"tw-bg-transparent",
"!tw-text-main",
"tw-border-transparent",
"hover:tw-bg-transparent-hover",
"hover:tw-border-text-main",
"focus-visible:before:tw-ring-text-main",
"disabled:hover:tw-bg-transparent",
],
muted: [
"tw-bg-transparent",
"!tw-text-muted",
"tw-border-transparent",
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-bg-transparent",
],
primary: [
"tw-bg-primary-500",
"!tw-text-contrast",
"tw-border-primary-500",
"hover:tw-bg-primary-700",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-bg-primary-500",
],
secondary: [
"tw-bg-transparent",
"!tw-text-muted",
"tw-border-text-muted",
"hover:!tw-text-contrast",
"hover:tw-bg-text-muted",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-muted",
"disabled:hover:tw-border-text-muted",
],
danger: [
"tw-bg-transparent",
"!tw-text-danger",
"tw-border-danger-500",
"hover:!tw-text-contrast",
"hover:tw-bg-danger-500",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-danger",
"disabled:hover:tw-border-danger-500",
],
};
export type IconButtonSize = "default" | "small";
const sizes: Record<IconButtonSize, string[]> = {
default: ["tw-px-2.5", "tw-py-1.5"],
small: ["tw-leading-none", "tw-text-base", "tw-p-1"],
};
@Component({
selector: "button[bitIconButton]",
template: `<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>`,
})
export class BitIconButtonComponent {
@Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonStyle = "main";
@Input() size: IconButtonSize = "default";
@HostBinding("class") get classList() {
return [
"tw-font-semibold",
"tw-border",
"tw-border-solid",
"tw-rounded",
"tw-transition",
"hover:tw-no-underline",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent",
"focus:tw-outline-none",
// Workaround for box-shadow with transparent offset issue:
// https://github.com/tailwindlabs/tailwindcss/issues/3595
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
"tw-relative",
"before:tw-content-['']",
"before:tw-block",
"before:tw-absolute",
"before:-tw-inset-[3px]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring",
"before:tw-ring-transparent",
"focus-visible:before:tw-ring-text-contrast",
"focus-visible:tw-z-10",
]
.concat(styles[this.buttonType])
.concat(sizes[this.size]);
}
get iconClass() {
return [this.icon, "!tw-m-0"];
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { BitIconButtonComponent } from "./icon-button.component";
@NgModule({
imports: [CommonModule],
declarations: [BitIconButtonComponent],
exports: [BitIconButtonComponent],
})
export class IconButtonModule {}

View File

@@ -0,0 +1,59 @@
import { Meta, Story } from "@storybook/angular";
import { BitIconButtonComponent } from "./icon-button.component";
export default {
title: "Component Library/Icon Button",
component: BitIconButtonComponent,
args: {
bitIconButton: "bwi-plus",
buttonType: "primary",
size: "default",
disabled: false,
},
} as Meta;
const Template: Story<BitIconButtonComponent> = (args: BitIconButtonComponent) => ({
props: args,
template: `
<div class="tw-p-5" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
[disabled]="disabled"
title="Example icon button"
aria-label="Example icon button"></button>
</div>
`,
});
export const Contrast = Template.bind({});
Contrast.args = {
buttonType: "contrast",
};
export const Main = Template.bind({});
Main.args = {
buttonType: "main",
};
export const Muted = Template.bind({});
Muted.args = {
buttonType: "muted",
};
export const Primary = Template.bind({});
Primary.args = {
buttonType: "primary",
};
export const Secondary = Template.bind({});
Secondary.args = {
buttonType: "secondary",
};
export const Danger = Template.bind({});
Danger.args = {
buttonType: "danger",
};

View File

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

View File

@@ -4,9 +4,9 @@ export * from "./button";
export * from "./callout";
export * from "./form-field";
export * from "./icon";
export * from "./icon-button";
export * from "./menu";
export * from "./dialog";
export * from "./submit-button";
export * from "./link";
export * from "./tabs";
export * from "./table";

View File

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

View File

@@ -1,10 +0,0 @@
<button bitButton type="submit" [buttonType]="buttonType" [disabled]="loading || disabled">
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<ng-content></ng-content>
</span>
<span class="tw-absolute tw-inset-0" [ngClass]="{ 'tw-invisible': !loading }">
<i class="bwi bwi-spinner bwi-lg bwi-spin tw-align-baseline" aria-hidden="true"></i>
</span>
</span>
</button>

View File

@@ -1,13 +0,0 @@
import { Component, Input } from "@angular/core";
import { ButtonTypes } from "../button";
@Component({
selector: "bit-submit-button",
templateUrl: "./submit-button.component.html",
})
export class SubmitButtonComponent {
@Input() buttonType: ButtonTypes = "primary";
@Input() disabled = false;
@Input() loading: boolean;
}

View File

@@ -1,13 +0,0 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { ButtonModule } from "../button";
import { SubmitButtonComponent } from "./submit-button.component";
@NgModule({
imports: [CommonModule, ButtonModule],
exports: [SubmitButtonComponent],
declarations: [SubmitButtonComponent],
})
export class SubmitButtonModule {}

View File

@@ -1,44 +0,0 @@
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { SubmitButtonComponent } from "./submit-button.component";
import { SubmitButtonModule } from "./submit-button.module";
export default {
title: "Component Library/Submit Button",
component: SubmitButtonComponent,
decorators: [
moduleMetadata({
imports: [SubmitButtonModule],
}),
],
args: {
buttonType: "primary",
loading: false,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A16733",
},
},
} as Meta;
const Template: Story<SubmitButtonComponent> = (args: SubmitButtonComponent) => ({
props: args,
template: `<bit-submit-button [buttonType]="buttonType" [loading]="loading" [disabled]="disabled">
Submit
</bit-submit-button>`,
});
export const Primary = Template.bind({});
Primary.args = {};
export const Loading = Template.bind({});
Loading.args = {
loading: true,
};
export const Disabled = Template.bind({});
Disabled.args = {
disabled: true,
};

View File

@@ -1,3 +1,5 @@
export * from "./tabs.module";
export * from "./tab-group.component";
export * from "./tab-item.component";
export * from "./tab-group/tab-group.component";
export * from "./tab-group/tab.component";
export * from "./tab-nav-bar/tab-nav-bar.component";
export * from "./tab-nav-bar/tab-link.component";

View File

@@ -0,0 +1,14 @@
import { Component } from "@angular/core";
/**
* Component used for styling the tab header/background for both content and navigation tabs
*/
@Component({
selector: "bit-tab-header",
host: {
class:
"tw-h-16 tw-pl-4 tw-bg-background-alt tw-flex tw-items-end tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300",
},
template: `<ng-content></ng-content>`,
})
export class TabHeaderComponent {}

View File

@@ -0,0 +1,12 @@
import { Directive } from "@angular/core";
/**
* Directive used for styling the container for bit tab labels
*/
@Directive({
selector: "[bitTabListContainer]",
host: {
class: "tw-inline-flex tw-flex-wrap tw-leading-5",
},
})
export class TabListContainerDirective {}

View File

@@ -0,0 +1,85 @@
import { FocusableOption } from "@angular/cdk/a11y";
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
/**
* Directive used for styling tab header items for both nav links (anchor tags)
* and content tabs (button tags)
*/
@Directive({ selector: "[bitTabListItem]" })
export class TabListItemDirective implements FocusableOption {
@Input() active: boolean;
@Input() disabled: boolean;
@HostBinding("attr.disabled")
get disabledAttr() {
return this.disabled || null; // native disabled attr must be null when false
}
constructor(private elementRef: ElementRef) {}
focus() {
this.elementRef.nativeElement.focus();
}
click() {
this.elementRef.nativeElement.click();
}
@HostBinding("class")
get classList(): string[] {
return this.baseClassList
.concat(this.active ? this.activeClassList : ["!tw-text-main"])
.concat(this.disabled ? this.disabledClassList : []);
}
get baseClassList(): string[] {
return [
"tw-block",
"tw-relative",
"tw-py-2",
"tw-px-4",
"tw-font-semibold",
"tw-transition",
"tw-rounded-t",
"tw-border-0",
"tw-border-x",
"tw-border-t-4",
"tw-border-transparent",
"tw-border-solid",
"tw-bg-transparent",
"tw-text-main",
"hover:tw-underline",
"hover:tw-text-main",
"focus-visible:tw-z-10",
"focus-visible:tw-outline-none",
"focus-visible:tw-ring-2",
"focus-visible:tw-ring-primary-700",
];
}
get disabledClassList(): string[] {
return [
"!tw-bg-secondary-100",
"!tw-text-muted",
"hover:!tw-text-muted",
"!tw-no-underline",
"tw-cursor-not-allowed",
];
}
get activeClassList(): string[] {
return [
"tw--mb-px",
"tw-border-x-secondary-300",
"tw-border-t-primary-500",
"tw-border-b",
"tw-border-b-background",
"tw-bg-background",
"!tw-text-primary-500",
"hover:tw-border-t-primary-700",
"hover:!tw-text-primary-700",
"focus-visible:tw-border-t-primary-700",
"focus-visible:!tw-text-primary-700",
];
}
}

View File

@@ -1,6 +0,0 @@
<div
role="tablist"
class="tw-inline-flex tw-flex-wrap tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-leading-5"
>
<ng-content></ng-content>
</div>

View File

@@ -1,7 +0,0 @@
import { Component } from "@angular/core";
@Component({
selector: "bit-tab-group",
templateUrl: "./tab-group.component.html",
})
export class TabGroupComponent {}

View File

@@ -0,0 +1 @@
<ng-template [cdkPortalOutlet]="tabContent"></ng-template>

View File

@@ -0,0 +1,45 @@
import { TemplatePortal } from "@angular/cdk/portal";
import { Component, HostBinding, Input } from "@angular/core";
@Component({
selector: "bit-tab-body",
templateUrl: "tab-body.component.html",
})
export class TabBodyComponent {
private _firstRender: boolean;
@Input() content: TemplatePortal;
@Input() preserveContent = false;
@HostBinding("attr.hidden") get hidden() {
return !this.active || null;
}
@Input()
get active() {
return this._active;
}
set active(value: boolean) {
this._active = value;
if (this._active) {
this._firstRender = true;
}
}
private _active: boolean;
/**
* The tab content to render.
* Inactive tabs that have never been rendered/active do not have their
* content rendered by default for performance. If `preserveContent` is `true`
* then the content persists after the first time content is rendered.
*/
get tabContent() {
if (this.active) {
return this.content;
}
if (this.preserveContent && this._firstRender) {
return this.content;
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
<bit-tab-header>
<div
bitTabListContainer
role="tablist"
[attr.aria-label]="label"
(keydown)="keyManager.onKeydown($event)"
>
<button
bitTabListItem
*ngFor="let tab of tabs; let i = index"
type="button"
role="tab"
[id]="getTabLabelId(i)"
[active]="tab.isActive"
[disabled]="tab.disabled"
[attr.aria-selected]="selectedIndex === i"
[attr.tabindex]="selectedIndex === i ? 0 : -1"
(click)="selectTab(i)"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
<ng-template #content>
<ng-template [ngIf]="tab.templateLabel" [ngIfElse]="tabTextLabel">
<ng-container [ngTemplateOutlet]="tab.templateLabel.templateRef"></ng-container>
</ng-template>
<ng-template #tabTextLabel>{{ tab.textLabel }}</ng-template>
</ng-template>
</button>
</div>
</bit-tab-header>
<div class="tw-px-4 tw-pt-5">
<bit-tab-body
role="tabpanel"
*ngFor="let tab of tabs; let i = index"
[id]="getTabContentId(i)"
[attr.tabindex]="selectedIndex === i ? 0 : -1"
[attr.labeledby]="getTabLabelId(i)"
[active]="tab.isActive"
[content]="tab.content"
[preserveContent]="preserveContent"
>
</bit-tab-body>
</div>

View File

@@ -0,0 +1,187 @@
import { FocusKeyManager } from "@angular/cdk/a11y";
import { coerceNumberProperty } from "@angular/cdk/coercion";
import {
AfterContentChecked,
AfterContentInit,
AfterViewInit,
Component,
ContentChildren,
EventEmitter,
Input,
OnDestroy,
Output,
QueryList,
ViewChildren,
} from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { TabListItemDirective } from "../shared/tab-list-item.directive";
import { TabComponent } from "./tab.component";
/** Used to generate unique ID's for each tab component */
let nextId = 0;
@Component({
selector: "bit-tab-group",
templateUrl: "./tab-group.component.html",
})
export class TabGroupComponent
implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy
{
private readonly _groupId: number;
private readonly destroy$ = new Subject<void>();
private _indexToSelect: number | null = 0;
/**
* Aria label for the tab list menu
*/
@Input() label = "";
/**
* Keep the content of off-screen tabs in the DOM.
* Useful for keeping <audio> or <video> elements from re-initializing
* after navigating between tabs.
*/
@Input() preserveContent = false;
@ContentChildren(TabComponent) tabs: QueryList<TabComponent>;
@ViewChildren(TabListItemDirective) tabLabels: QueryList<TabListItemDirective>;
/** The index of the active tab. */
@Input()
get selectedIndex(): number | null {
return this._selectedIndex;
}
set selectedIndex(value: number) {
this._indexToSelect = coerceNumberProperty(value, null);
}
private _selectedIndex: number | null = null;
/** Output to enable support for two-way binding on `[(selectedIndex)]` */
@Output() readonly selectedIndexChange: EventEmitter<number> = new EventEmitter<number>();
/** Event emitted when the tab selection has changed. */
@Output() readonly selectedTabChange: EventEmitter<BitTabChangeEvent> =
new EventEmitter<BitTabChangeEvent>();
/**
* Focus key manager for keeping tab controls accessible.
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role#keyboard_interactions
*/
keyManager: FocusKeyManager<TabListItemDirective>;
constructor() {
this._groupId = nextId++;
}
protected getTabContentId(id: number): string {
return `bit-tab-content-${this._groupId}-${id}`;
}
protected getTabLabelId(id: number): string {
return `bit-tab-label-${this._groupId}-${id}`;
}
selectTab(index: number) {
this.selectedIndex = index;
}
/**
* After content is checked, the tab group knows what tabs are defined and which index
* should be currently selected.
*/
ngAfterContentChecked(): void {
const indexToSelect = (this._indexToSelect = this._clampTabIndex(this._indexToSelect));
if (this._selectedIndex != indexToSelect) {
const isFirstRun = this._selectedIndex == null;
if (!isFirstRun) {
this.selectedTabChange.emit({
index: indexToSelect,
tab: this.tabs.toArray()[indexToSelect],
});
}
// These values need to be updated after change detection as
// the checked content may have references to them.
Promise.resolve().then(() => {
this.tabs.forEach((tab, index) => (tab.isActive = index === indexToSelect));
if (!isFirstRun) {
this.selectedIndexChange.emit(indexToSelect);
}
});
// Manually update the _selectedIndex and keyManager active item
this._selectedIndex = indexToSelect;
if (this.keyManager) {
this.keyManager.setActiveItem(indexToSelect);
}
}
}
ngAfterViewInit(): void {
this.keyManager = new FocusKeyManager(this.tabLabels)
.withHorizontalOrientation("ltr")
.withWrap()
.withHomeAndEnd();
}
ngAfterContentInit() {
// Subscribe to any changes in the number of tabs, in order to be able
// to re-render content when new tabs are added or removed.
this.tabs.changes.pipe(takeUntil(this.destroy$)).subscribe(() => {
const indexToSelect = this._clampTabIndex(this._indexToSelect);
// If the selected tab didn't explicitly change, keep the previously
// selected tab selected/active
if (indexToSelect === this._selectedIndex) {
const tabs = this.tabs.toArray();
let selectedTab: TabComponent | undefined;
for (let i = 0; i < tabs.length; i++) {
if (tabs[i].isActive) {
// Set both _indexToSelect and _selectedIndex to avoid firing a change
// event which could cause an infinite loop if adding a tab within the
// selectedIndexChange event
this._indexToSelect = this._selectedIndex = i;
selectedTab = tabs[i];
break;
}
}
// No active tab found and a tab does exist means the active tab
// was removed, so a new active tab must be set manually
if (!selectedTab && tabs[indexToSelect]) {
tabs[indexToSelect].isActive = true;
this.selectedTabChange.emit({
index: indexToSelect,
tab: tabs[indexToSelect],
});
}
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
private _clampTabIndex(index: number): number {
return Math.min(this.tabs.length - 1, Math.max(index || 0, 0));
}
}
export class BitTabChangeEvent {
/**
* The currently selected tab index
*/
index: number;
/**
* The currently selected tab
*/
tab: TabComponent;
}

View File

@@ -0,0 +1,22 @@
import { Directive, TemplateRef } from "@angular/core";
/**
* Used to identify template based tab labels (allows complex labels instead of just plaintext)
*
* @example
* ```
* <bit-tab>
* <ng-template bitTabLabel>
* <i class="bwi bwi-search"></i> Search
* </ng-template>
*
* <p>Tab Content</p>
* </bit-tab>
* ```
*/
@Directive({
selector: "[bitTabLabel]",
})
export class TabLabelDirective {
constructor(public templateRef: TemplateRef<unknown>) {}
}

View File

@@ -0,0 +1 @@
<ng-template><ng-content></ng-content></ng-template>

View File

@@ -0,0 +1,42 @@
import { TemplatePortal } from "@angular/cdk/portal";
import {
Component,
ContentChild,
Input,
OnInit,
TemplateRef,
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { TabLabelDirective } from "./tab-label.directive";
@Component({
selector: "bit-tab",
templateUrl: "./tab.component.html",
host: {
role: "tabpanel",
},
})
export class TabComponent implements OnInit {
@Input() disabled = false;
@Input("label") textLabel = "";
@ViewChild(TemplateRef, { static: true }) implicitContent: TemplateRef<unknown>;
@ContentChild(TabLabelDirective) templateLabel: TabLabelDirective;
private _contentPortal: TemplatePortal | null = null;
get content(): TemplatePortal | null {
return this._contentPortal;
}
isActive: boolean;
constructor(private _viewContainerRef: ViewContainerRef) {}
ngOnInit(): void {
this._contentPortal = new TemplatePortal(this.implicitContent, this._viewContainerRef);
}
}

View File

@@ -1,26 +0,0 @@
<ng-container [ngSwitch]="disabled">
<a
*ngSwitchCase="false"
role="tab"
[class]="baseClassList"
[routerLink]="route"
[routerLinkActive]="activeClassList"
#rla="routerLinkActive"
[attr.aria-selected]="rla.isActive"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
</a>
<button
*ngSwitchCase="true"
type="button"
role="tab"
[class]="baseClassList"
disabled
aria-disabled="true"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
</button>
</ng-container>
<ng-template #content>
<ng-content></ng-content>
</ng-template>

View File

@@ -1,54 +0,0 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "bit-tab-item",
templateUrl: "./tab-item.component.html",
})
export class TabItemComponent {
@Input() route: string; // ['/route']
@Input() disabled = false;
get baseClassList(): string[] {
return [
"tw-block",
"tw-relative",
"tw-py-2",
"tw-px-4",
"tw-font-semibold",
"tw-transition",
"tw-rounded-t",
"tw-border-0",
"tw-border-x",
"tw-border-t-4",
"tw-border-transparent",
"tw-border-solid",
"!tw-text-main",
"hover:tw-underline",
"hover:!tw-text-main",
"focus-visible:tw-z-10",
"focus-visible:tw-outline-none",
"focus-visible:tw-ring-2",
"focus-visible:tw-ring-primary-700",
"disabled:tw-bg-transparent",
"disabled:!tw-text-muted/60",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
];
}
get activeClassList(): string {
return [
"tw--mb-px",
"tw-border-x-secondary-300",
"tw-border-t-primary-500",
"tw-border-b",
"tw-border-b-background",
"tw-bg-background",
"!tw-text-primary-500",
"hover:tw-border-t-primary-700",
"hover:!tw-text-primary-700",
"focus-visible:tw-border-t-primary-700",
"focus-visible:!tw-text-primary-700",
].join(" ");
}
}

View File

@@ -0,0 +1,13 @@
<a
bitTabListItem
[routerLink]="disabled ? null : route"
routerLinkActive
#rla="routerLinkActive"
[active]="rla.isActive"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
ariaCurrentWhenActive="page"
role="link"
>
<ng-content></ng-content>
</a>

View File

@@ -0,0 +1,51 @@
import { FocusableOption } from "@angular/cdk/a11y";
import { AfterViewInit, Component, HostListener, Input, OnDestroy, ViewChild } from "@angular/core";
import { RouterLinkActive } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { TabListItemDirective } from "../shared/tab-list-item.directive";
import { TabNavBarComponent } from "./tab-nav-bar.component";
@Component({
selector: "bit-tab-link",
templateUrl: "tab-link.component.html",
})
export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestroy {
private destroy$ = new Subject<void>();
@ViewChild(TabListItemDirective) tabItem: TabListItemDirective;
@ViewChild("rla") routerLinkActive: RouterLinkActive;
@Input() route: string;
@Input() disabled = false;
@HostListener("keydown", ["$event"]) onKeyDown(event: KeyboardEvent) {
if (event.code === "Space") {
this.tabItem.click();
}
}
get active() {
return this.routerLinkActive?.isActive ?? false;
}
constructor(private _tabNavBar: TabNavBarComponent) {}
focus(): void {
this.tabItem.focus();
}
ngAfterViewInit() {
// The active state of tab links are tracked via the routerLinkActive directive
// We need to watch for changes to tell the parent nav group when the tab is active
this.routerLinkActive.isActiveChange
.pipe(takeUntil(this.destroy$))
.subscribe((_) => this._tabNavBar.updateActiveLink());
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,5 @@
<bit-tab-header>
<nav bitTabListContainer [attr.aria-label]="label" (keydown)="keyManager.onKeydown($event)">
<ng-content></ng-content>
</nav>
</bit-tab-header>

View File

@@ -0,0 +1,43 @@
import { FocusKeyManager } from "@angular/cdk/a11y";
import {
AfterContentInit,
Component,
ContentChildren,
forwardRef,
Input,
QueryList,
} from "@angular/core";
import { TabLinkComponent } from "./tab-link.component";
@Component({
selector: "bit-tab-nav-bar",
templateUrl: "tab-nav-bar.component.html",
})
export class TabNavBarComponent implements AfterContentInit {
@ContentChildren(forwardRef(() => TabLinkComponent)) tabLabels: QueryList<TabLinkComponent>;
@Input() label = "";
/**
* Focus key manager for keeping tab controls accessible.
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role#keyboard_interactions
*/
keyManager: FocusKeyManager<TabLinkComponent>;
ngAfterContentInit(): void {
this.keyManager = new FocusKeyManager(this.tabLabels)
.withHorizontalOrientation("ltr")
.withWrap()
.withHomeAndEnd();
}
updateActiveLink() {
// Keep the keyManager in sync with active tabs
const items = this.tabLabels.toArray();
for (let i = 0; i < items.length; i++) {
if (items[i].active) {
this.keyManager.updateActiveItem(i);
}
}
}
}

View File

@@ -1,13 +1,37 @@
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { TabGroupComponent } from "./tab-group.component";
import { TabItemComponent } from "./tab-item.component";
import { TabHeaderComponent } from "./shared/tab-header.component";
import { TabListContainerDirective } from "./shared/tab-list-container.directive";
import { TabListItemDirective } from "./shared/tab-list-item.directive";
import { TabBodyComponent } from "./tab-group/tab-body.component";
import { TabGroupComponent } from "./tab-group/tab-group.component";
import { TabLabelDirective } from "./tab-group/tab-label.directive";
import { TabComponent } from "./tab-group/tab.component";
import { TabLinkComponent } from "./tab-nav-bar/tab-link.component";
import { TabNavBarComponent } from "./tab-nav-bar/tab-nav-bar.component";
@NgModule({
imports: [CommonModule, RouterModule],
exports: [TabGroupComponent, TabItemComponent],
declarations: [TabGroupComponent, TabItemComponent],
imports: [CommonModule, RouterModule, PortalModule],
exports: [
TabGroupComponent,
TabComponent,
TabLabelDirective,
TabNavBarComponent,
TabLinkComponent,
],
declarations: [
TabGroupComponent,
TabComponent,
TabLabelDirective,
TabListContainerDirective,
TabListItemDirective,
TabHeaderComponent,
TabNavBarComponent,
TabLinkComponent,
TabBodyComponent,
],
})
export class TabsModule {}

View File

@@ -3,8 +3,8 @@ import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { TabGroupComponent } from "./tab-group.component";
import { TabItemComponent } from "./tab-item.component";
import { TabGroupComponent } from "./tab-group/tab-group.component";
import { TabsModule } from "./tabs.module";
@Component({
selector: "bit-tab-active-dummy",
@@ -36,8 +36,6 @@ export default {
decorators: [
moduleMetadata({
declarations: [
TabGroupComponent,
TabItemComponent,
ActiveDummyComponent,
ItemTwoDummyComponent,
ItemThreeDummyComponent,
@@ -45,6 +43,7 @@ export default {
],
imports: [
CommonModule,
TabsModule,
RouterModule.forRoot(
[
{ path: "", redirectTo: "active", pathMatch: "full" },
@@ -66,19 +65,63 @@ export default {
},
} as Meta;
const TabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
const ContentTabGroupTemplate: Story<TabGroupComponent> = (args: any) => ({
props: args,
template: `
<bit-tab-group>
<bit-tab-item [route]="['active']">Active</bit-tab-item>
<bit-tab-item [route]="['item-2']">Item 2</bit-tab-item>
<bit-tab-item [route]="['item-3']">Item 3</bit-tab-item>
<bit-tab-item [route]="['disabled']" [disabled]="true">Disabled</bit-tab-item>
<bit-tab-group label="Main Content Tabs" class="tw-text-main">
<bit-tab label="First Tab">First Tab Content</bit-tab>
<bit-tab label="Second Tab">Second Tab Content</bit-tab>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-search tw-pr-1"></i> Template Label
</ng-template>
Template Label Content
</bit-tab>
<bit-tab [disabled]="true" label="Disabled">
Disabled Content
</bit-tab>
</bit-tab-group>
<div class="tw-bg-transparent tw-text-semibold tw-text-center !tw-text-main tw-py-10">
`,
});
export const ContentTabs = ContentTabGroupTemplate.bind({});
const NavTabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
props: args,
template: `
<bit-tab-nav-bar label="Main">
<bit-tab-link [route]="['active']">Active</bit-tab-link>
<bit-tab-link [route]="['item-2']">Item 2</bit-tab-link>
<bit-tab-link [route]="['item-3']">Item 3</bit-tab-link>
<bit-tab-link [route]="['disable']" [disabled]="true">Disabled</bit-tab-link>
</bit-tab-nav-bar>
<div class="tw-bg-transparent tw-text-semibold tw-text-center tw-text-main tw-py-10">
<router-outlet></router-outlet>
</div>
`,
});
export const TabGroup = TabGroupTemplate.bind({});
export const NavigationTabs = NavTabGroupTemplate.bind({});
const PreserveContentTabGroupTemplate: Story<TabGroupComponent> = (args: any) => ({
props: args,
template: `
<bit-tab-group label="Preserve Content Tabs" [preserveContent]="true" class="tw-text-main">
<bit-tab label="Text Tab">
<p>
Play the video in the other tab and switch back to hear the video is still playing.
</p>
</bit-tab>
<bit-tab label="Video Tab">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/H0-yWbe5XG4"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</bit-tab>
</bit-tab-group>
`,
});
export const PreserveContentTabs = PreserveContentTabGroupTemplate.bind({});

View File

@@ -1,4 +1,6 @@
:root {
--color-transparent-hover: rgb(0 0 0 / 0.03);
--color-background: 255 255 255;
--color-background-alt: 251 251 251;
--color-background-alt2: 23 92 219;
@@ -37,6 +39,8 @@
}
.theme_dark {
--color-transparent-hover: rgb(255 255 255 / 0.12);
--color-background: 31 36 46;
--color-background-alt: 22 28 38;
--color-background-alt2: 47 52 61;

View File

@@ -12,7 +12,10 @@ module.exports = {
corePlugins: { preflight: false },
theme: {
colors: {
transparent: colors.transparent,
transparent: {
DEFAULT: colors.transparent,
hover: "var(--color-transparent-hover)",
},
current: colors.current,
black: colors.black,
primary: {