1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 00:33:44 +00:00

Merge master into merge/feature/org-admin-refresh (using imerge)

This commit is contained in:
Shane Melton
2022-12-13 07:31:22 -08:00
721 changed files with 18307 additions and 5660 deletions

View File

@@ -1,6 +1,7 @@
import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core";
import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
@@ -23,7 +24,8 @@ export class BitActionDirective implements OnDestroy {
constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService
@Optional() private validationService?: ValidationService,
@Optional() private logService?: LogService
) {}
get loading() {
@@ -44,7 +46,12 @@ export class BitActionDirective implements OnDestroy {
this.loading = true;
functionToObservable(this.handler)
.pipe(
tap({ error: (err: unknown) => this.validationService?.showError(err) }),
tap({
error: (err: unknown) => {
this.logService?.error(`Async action exception: ${err}`);
this.validationService?.showError(err);
},
}),
finalize(() => (this.loading = false)),
takeUntil(this.destroy$)
)

View File

@@ -2,6 +2,7 @@ import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core";
import { FormGroupDirective } from "@angular/forms";
import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable";
@@ -24,7 +25,8 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
constructor(
private formGroupDirective: FormGroupDirective,
@Optional() validationService?: ValidationService
@Optional() validationService?: ValidationService,
@Optional() logService?: LogService
) {
formGroupDirective.ngSubmit
.pipe(
@@ -39,6 +41,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
return awaitable.pipe(
catchError((err: unknown) => {
logService?.error(`Async submit exception: ${err}`);
validationService?.showError(err);
return of(undefined);
})

View File

@@ -3,6 +3,7 @@ import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { delay, of } from "rxjs";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { ButtonModule } from "../button";
@@ -68,6 +69,12 @@ export default {
showError: action("ValidationService.showError"),
} as Partial<ValidationService>,
},
{
provide: LogService,
useValue: {
error: action("LogService.error"),
} as Partial<LogService>,
},
],
}),
],

View File

@@ -7,7 +7,7 @@ type SizeTypes = "large" | "default" | "small";
const SizeClasses: Record<SizeTypes, string[]> = {
large: ["tw-h-16", "tw-w-16"],
default: ["tw-h-12", "tw-w-12"],
default: ["tw-h-10", "tw-w-10"],
small: ["tw-h-7", "tw-w-7"],
};

View File

@@ -1,5 +1,6 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<i class="bwi bwi-lg" [ngClass]="iconClass" aria-hidden="true" *ngIf="icon"></i>
<ng-content></ng-content>
</span>
<span

View File

@@ -20,8 +20,8 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
"tw-bg-transparent",
"tw-border-text-muted",
"!tw-text-muted",
"hover:tw-bg-secondary-500",
"hover:tw-border-secondary-500",
"hover:tw-bg-text-muted",
"hover:tw-border-text-muted",
"hover:!tw-text-contrast",
"disabled:tw-bg-transparent",
"disabled:tw-border-text-muted/60",
@@ -76,7 +76,16 @@ export class ButtonComponent implements ButtonLikeAbstraction {
}
@Input() buttonType: ButtonTypes = null;
@Input() block?: boolean;
@Input() loading = false;
@Input() disabled = false;
@Input("bitIconButton") icon: string;
get iconClass() {
return [this.icon, "!tw-m-0"];
}
}

View File

@@ -101,3 +101,17 @@ export const Block = BlockTemplate.bind({});
Block.args = {
block: true,
};
const IconTemplate: Story = (args) => ({
props: args,
template: `
<button bitButton [bitIconButton]="icon" buttonType="primary" class="tw-mr-2"></button>
<button bitButton [bitIconButton]="icon"buttonType="secondary" class="tw-mr-2"></button>
<button bitButton [bitIconButton]="icon" buttonType="danger" class="tw-mr-2"></button>
`,
});
export const Icon = IconTemplate.bind({});
Icon.args = {
icon: "bwi-eye",
};

View File

@@ -0,0 +1,104 @@
import { Component, HostBinding, Input, Optional, Self } from "@angular/core";
import { NgControl, Validators } from "@angular/forms";
import { BitFormControlAbstraction } from "../form-control";
@Component({
selector: "input[type=checkbox][bitCheckbox]",
template: "",
providers: [{ provide: BitFormControlAbstraction, useExisting: CheckboxComponent }],
styles: [
`
:host:checked:before {
-webkit-mask-image: 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');
mask-image: 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');
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
`,
],
})
export class CheckboxComponent implements BitFormControlAbstraction {
@HostBinding("class")
protected inputClasses = [
"tw-appearance-none",
"tw-outline-none",
"tw-relative",
"tw-transition",
"tw-cursor-pointer",
"tw-inline-block",
"tw-rounded",
"tw-border",
"tw-border-solid",
"tw-border-secondary-500",
"tw-h-3.5",
"tw-w-3.5",
"tw-mr-1.5",
"tw-bottom-[-1px]", // Fix checkbox looking off-center
"before:tw-content-['']",
"before:tw-block",
"before:tw-absolute",
"before:tw-inset-0",
"hover:tw-border-2",
"[&>label]:tw-border-2",
"focus-visible:tw-ring-2",
"focus-visible:tw-ring-offset-2",
"focus-visible:tw-ring-primary-700",
"disabled:tw-cursor-auto",
"disabled:tw-border",
"disabled:tw-bg-secondary-100",
"checked:tw-bg-primary-500",
"checked:tw-border-primary-500",
"checked:hover:tw-bg-primary-700",
"checked:hover:tw-border-primary-700",
"[&>label:hover]:checked:tw-bg-primary-700",
"[&>label:hover]:checked:tw-border-primary-700",
"checked:before:tw-bg-text-contrast",
"checked:disabled:tw-border-secondary-100",
"checked:disabled:tw-bg-secondary-100",
"checked:disabled:before:tw-bg-text-muted",
];
constructor(@Optional() @Self() private ngControl?: NgControl) {}
@HostBinding()
@Input()
get disabled() {
return this._disabled ?? this.ngControl?.disabled ?? false;
}
set disabled(value: any) {
this._disabled = value != null && value !== false;
}
private _disabled: boolean;
@Input()
get required() {
return (
this._required ?? this.ngControl?.control?.hasValidator(Validators.requiredTrue) ?? false
);
}
set required(value: any) {
this._required = value != null && value !== false;
}
private _required: boolean;
get hasError() {
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
}
get error(): [string, any] {
const key = Object.keys(this.ngControl.errors)[0];
return [key, this.ngControl.errors[key]];
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormControlModule } from "../form-control";
import { SharedModule } from "../shared";
import { CheckboxComponent } from "./checkbox.component";
@NgModule({
imports: [SharedModule, CommonModule, FormControlModule],
declarations: [CheckboxComponent],
exports: [CheckboxComponent],
})
export class CheckboxModule {}

View File

@@ -0,0 +1,111 @@
import { Component, Input } from "@angular/core";
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service";
import { FormControlModule } from "../form-control";
import { I18nMockService } from "../utils/i18n-mock.service";
import { CheckboxModule } from "./checkbox.module";
const template = `
<form [formGroup]="formObj">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkbox">
<bit-label>Click me</bit-label>
</bit-form-control>
</form>`;
@Component({
selector: "app-example",
template,
})
class ExampleComponent {
protected formObj = this.formBuilder.group({
checkbox: [false, Validators.requiredTrue],
});
@Input() set checked(value: boolean) {
this.formObj.patchValue({ checkbox: value });
}
@Input() set disabled(disable: boolean) {
if (disable) {
this.formObj.disable();
} else {
this.formObj.enable();
}
}
constructor(private formBuilder: FormBuilder) {}
}
export default {
title: "Component Library/Form/Checkbox",
decorators: [
moduleMetadata({
declarations: [ExampleComponent],
imports: [FormsModule, ReactiveFormsModule, FormControlModule, CheckboxModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
required: "required",
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=3930%3A16850&t=xXPx6GJYsJfuMQPE-4",
},
},
} as Meta;
const DefaultTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
props: args,
template: `<app-example [checked]="checked" [disabled]="disabled"></app-example>`,
});
export const Default = DefaultTemplate.bind({});
Default.parameters = {
docs: {
source: {
code: template,
},
},
};
Default.args = {
checked: false,
disabled: false,
};
const CustomTemplate: Story = (args) => ({
props: args,
template: `
<div class="tw-flex tw-flex-col tw-w-32">
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
A-Z
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
a-z
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
0-9
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
</label>
</div>
`,
});
CustomTemplate.args = {};
export const Custom = CustomTemplate.bind({});

View File

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

View File

@@ -9,7 +9,7 @@ export class DialogComponent {
@Input() dialogSize: "small" | "default" | "large" = "default";
private _disablePadding: boolean;
@Input() set disablePadding(value: boolean) {
@Input() set disablePadding(value: boolean | string) {
this._disablePadding = coerceBooleanProperty(value);
}
get disablePadding() {

View File

@@ -0,0 +1,6 @@
export abstract class BitFormControlAbstraction {
disabled: boolean;
required: boolean;
hasError: boolean;
error: [string, any];
}

View File

@@ -0,0 +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>
</label>
<div>
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
</div>
<div *ngIf="hasError" class="tw-mt-1 tw-text-danger">
<i class="bwi bwi-error"></i> {{ displayError }}
</div>

View File

@@ -0,0 +1,68 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, ContentChild, HostBinding, Input } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { BitFormControlAbstraction } from "./form-control.abstraction";
@Component({
selector: "bit-form-control",
templateUrl: "form-control.component.html",
})
export class FormControlComponent {
@Input() label: string;
private _inline: boolean;
@Input() get inline() {
return this._inline;
}
set inline(value: boolean | string | null) {
this._inline = coerceBooleanProperty(value);
}
@ContentChild(BitFormControlAbstraction) protected formControl: BitFormControlAbstraction;
@HostBinding("class") get classes() {
return ["tw-mb-6"].concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"]);
}
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"
);
}
protected get labelContentClasses() {
return ["tw-font-semibold"].concat(
this.formControl.disabled ? "tw-text-muted" : "tw-text-main"
);
}
get required() {
return this.formControl.required;
}
get hasError() {
return this.formControl.hasError;
}
get error() {
return this.formControl.error;
}
get displayError() {
switch (this.error[0]) {
case "required":
return this.i18nService.t("inputRequired");
default:
// Attempt to show a custom error message.
if (this.error[1]?.message) {
return this.error[1]?.message;
}
return this.error;
}
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../shared";
import { FormControlComponent } from "./form-control.component";
import { BitHintComponent } from "./hint.component";
import { BitLabel } from "./label.directive";
@NgModule({
imports: [SharedModule],
declarations: [FormControlComponent, BitLabel, BitHintComponent],
exports: [FormControlComponent, BitLabel, BitHintComponent],
})
export class FormControlModule {}

View File

@@ -0,0 +1,3 @@
export * from "./form-control.module";
export * from "./form-control.abstraction";
export * from "./form-control.component";

View File

@@ -1,3 +1,12 @@
export type InputTypes =
| "text"
| "password"
| "number"
| "datetime-local"
| "email"
| "checkbox"
| "search";
export abstract class BitFormFieldControl {
ariaDescribedBy: string;
id: string;
@@ -5,4 +14,7 @@ export abstract class BitFormFieldControl {
required: boolean;
hasError: boolean;
error: [string, any];
type?: InputTypes;
spellcheck?: boolean;
focus?: () => void;
}

View File

@@ -7,9 +7,10 @@ import {
ViewChild,
} from "@angular/core";
import { BitHintComponent } from "../form-control/hint.component";
import { BitErrorComponent } from "./error.component";
import { BitFormFieldControl } from "./form-field-control";
import { BitHintComponent } from "./hint.component";
import { BitPrefixDirective } from "./prefix.directive";
import { BitSuffixDirective } from "./suffix.directive";

View File

@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { FormControlModule } from "../form-control";
import { BitInputDirective } from "../input/input.directive";
import { InputModule } from "../input/input.module";
import { MultiSelectComponent } from "../multi-select/multi-select.component";
@@ -9,32 +10,30 @@ import { SharedModule } from "../shared";
import { BitErrorSummary } from "./error-summary.component";
import { BitErrorComponent } from "./error.component";
import { BitFormFieldComponent } from "./form-field.component";
import { BitHintComponent } from "./hint.component";
import { BitLabel } from "./label.directive";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
import { BitPrefixDirective } from "./prefix.directive";
import { BitSuffixDirective } from "./suffix.directive";
@NgModule({
imports: [SharedModule, InputModule, MultiSelectModule],
exports: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitHintComponent,
BitLabel,
BitPrefixDirective,
BitSuffixDirective,
BitInputDirective,
MultiSelectComponent,
],
imports: [SharedModule, FormControlModule, InputModule, MultiSelectModule],
declarations: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitHintComponent,
BitLabel,
BitPasswordInputToggleDirective,
BitPrefixDirective,
BitSuffixDirective,
],
exports: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitInputDirective,
BitPasswordInputToggleDirective,
BitPrefixDirective,
BitSuffixDirective,
MultiSelectComponent,
FormControlModule,
],
})
export class FormFieldModule {}

View File

@@ -12,7 +12,9 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { CheckboxModule } from "../checkbox";
import { InputModule } from "../input/input.module";
import { RadioButtonModule } from "../radio-button";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BitFormFieldComponent } from "./form-field.component";
@@ -23,7 +25,15 @@ export default {
component: BitFormFieldComponent,
decorators: [
moduleMetadata({
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
imports: [
FormsModule,
ReactiveFormsModule,
FormFieldModule,
InputModule,
ButtonModule,
CheckboxModule,
RadioButtonModule,
],
providers: [
{
provide: I18nService,
@@ -55,6 +65,8 @@ const formObj = fb.group({
const defaultFormObj = fb.group({
name: ["", [Validators.required]],
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
terms: [false, [Validators.requiredTrue]],
updates: ["yes"],
});
// Custom error message, `message` is shown as the error message
@@ -166,13 +178,9 @@ const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldCom
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" />
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
</button>
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
<input bitInput placeholder="Placeholder" type="password" />
<button bitSuffix bitButton bitIconButton="bwi-eye"></button>
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
</bit-form-field>
`,
});
@@ -188,12 +196,8 @@ const DisabledButtonInputGroupTemplate: Story<BitFormFieldComponent> = (
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" disabled />
<button bitSuffix bitButton disabled>
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
</button>
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
<button bitSuffix bitButton bitIconButton="bwi-eye" disabled></button>
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
</bit-form-field>
`,
});

View File

@@ -0,0 +1,54 @@
import {
AfterContentInit,
Directive,
EventEmitter,
Host,
HostListener,
Input,
OnChanges,
Output,
} from "@angular/core";
import { ButtonComponent } from "../button";
import { BitFormFieldComponent } from "./form-field.component";
@Directive({
selector: "[bitPasswordInputToggle]",
})
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
@Input() toggled = false;
@Output() toggledChange = new EventEmitter<boolean>();
@HostListener("click") onClick() {
this.toggled = !this.toggled;
this.toggledChange.emit(this.toggled);
this.update();
this.formField.input?.focus();
}
constructor(@Host() private button: ButtonComponent, private formField: BitFormFieldComponent) {}
get icon() {
return this.toggled ? "bwi-eye-slash" : "bwi-eye";
}
ngOnChanges(): void {
this.update();
}
ngAfterContentInit(): void {
this.toggled = this.formField.input.type !== "password";
this.button.icon = this.icon;
}
private update() {
this.button.icon = this.icon;
if (this.formField.input?.type != null) {
this.formField.input.type = this.toggled ? "text" : "password";
this.formField.input.spellcheck = this.toggled ? false : undefined;
}
}
}

View File

@@ -0,0 +1,100 @@
import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ButtonComponent, ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { BitFormFieldControl } from "./form-field-control";
import { BitFormFieldComponent } from "./form-field.component";
import { FormFieldModule } from "./form-field.module";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
@Component({
selector: "test-form-field",
template: `
<form>
<bit-form-field>
<bit-label>Password</bit-label>
<input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
</form>
`,
})
class TestFormFieldComponent {}
describe("PasswordInputToggle", () => {
let fixture: ComponentFixture<TestFormFieldComponent>;
let button: ButtonComponent;
let input: BitFormFieldControl;
let toggle: DebugElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormFieldModule, ButtonModule, InputModule],
declarations: [TestFormFieldComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestFormFieldComponent);
fixture.detectChanges();
toggle = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective));
const buttonEl = fixture.debugElement.query(By.directive(ButtonComponent));
button = buttonEl.componentInstance;
const formFieldEl = fixture.debugElement.query(By.directive(BitFormFieldComponent));
const formField: BitFormFieldComponent = formFieldEl.componentInstance;
input = formField.input;
});
describe("initial state", () => {
it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye");
});
it("input is type password", () => {
expect(input.type).toBe("password");
});
it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(undefined);
});
});
describe("when toggled", () => {
beforeEach(() => {
toggle.triggerEventHandler("click");
});
it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye-slash");
});
it("input is type text", () => {
expect(input.type).toBe("text");
});
it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(false);
});
});
describe("when toggled twice", () => {
beforeEach(() => {
toggle.triggerEventHandler("click");
toggle.triggerEventHandler("click");
});
it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye");
});
it("input is type password", () => {
expect(input.type).toBe("password");
});
it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(undefined);
});
});
});

View File

@@ -0,0 +1,77 @@
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { FormFieldModule } from "./form-field.module";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
export default {
title: "Component Library/Form/Password Toggle",
component: BitPasswordInputToggleDirective,
decorators: [
moduleMetadata({
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689",
},
docs: {
description: {
component:
"Directive for toggling the visibility of a password input. Works by either having living inside a `bit-form-field` or by using the `toggled` two-way binding.",
},
},
},
} as Meta;
const Template: Story<BitPasswordInputToggleDirective> = (
args: BitPasswordInputToggleDirective
) => ({
props: {
...args,
},
template: `
<form>
<bit-form-field>
<bit-label>Password</bit-label>
<input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
</form>
`,
});
export const Default = Template.bind({});
Default.props = {};
const TemplateBinding: Story<BitPasswordInputToggleDirective> = (
args: BitPasswordInputToggleDirective
) => ({
props: {
...args,
},
template: `
<form>
<bit-form-field>
<bit-label>Password</bit-label>
<input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle [(toggled)]="toggled"></button>
</bit-form-field>
<label class="tw-text-main">
Checked:
<input type="checkbox" [(ngModel)]="toggled" [ngModelOptions]="{standalone: true}" />
</label>
</form>
`,
});
export const Binding = TemplateBinding.bind({});
Binding.props = {
toggled: false,
};

View File

@@ -0,0 +1,111 @@
import {
AbstractControl,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators,
FormBuilder,
} from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { CheckboxModule } from "../checkbox";
import { FormControlModule } from "../form-control";
import { FormFieldModule } from "../form-field";
import { InputModule } from "../input/input.module";
import { RadioButtonModule } from "../radio-button";
import { I18nMockService } from "../utils/i18n-mock.service";
export default {
title: "Component Library/Form",
decorators: [
moduleMetadata({
imports: [
FormsModule,
ReactiveFormsModule,
FormFieldModule,
InputModule,
ButtonModule,
FormControlModule,
CheckboxModule,
RadioButtonModule,
],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
required: "required",
checkboxRequired: "Option is required",
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A17689",
},
},
} as Meta;
const fb = new FormBuilder();
const exampleFormObj = fb.group({
name: ["", [Validators.required]],
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
terms: [false, [Validators.requiredTrue]],
updates: ["yes"],
});
// Custom error message, `message` is shown as the error message
function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value);
return forbidden ? { forbiddenName: { message: "forbiddenName" } } : null;
};
}
const FullExampleTemplate: Story = (args) => ({
props: {
formObj: exampleFormObj,
submit: () => exampleFormObj.markAllAsTouched(),
...args,
},
template: `
<form [formGroup]="formObj" (ngSubmit)="submit()">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" />
</bit-form-field>
<bit-form-control>
<bit-label>Agree to terms</bit-label>
<input type="checkbox" bitCheckbox formControlName="terms">
<bit-hint>Required for the service to work properly</bit-hint>
</bit-form-control>
<bit-radio-group formControlName="updates">
<bit-label>Subscribe to updates?</bit-label>
<bit-radio-button value="yes">Yes</bit-radio-button>
<bit-radio-button value="no">No</bit-radio-button>
<bit-radio-button value="later">Decide later</bit-radio-button>
</bit-radio-group>
<button type="submit" bitButton buttonType="primary">Submit</button>
</form>
`,
});
export const FullExample = FullExampleTemplate.bind({});

View File

@@ -79,7 +79,7 @@ const sizes: Record<IconButtonSize, string[]> = {
};
@Component({
selector: "button[bitIconButton]",
selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
})

View File

@@ -5,6 +5,8 @@ export * from "./badge-list";
export * from "./banner";
export * from "./button";
export * from "./callout";
export * from "./checkbox";
export * from "./color-password";
export * from "./dialog";
export * from "./form-field";
export * from "./icon-button";
@@ -12,8 +14,8 @@ export * from "./icon";
export * from "./link";
export * from "./menu";
export * from "./multi-select";
export * from "./tabs";
export * from "./navigation";
export * from "./table";
export * from "./tabs";
export * from "./toggle-group";
export * from "./color-password";
export * from "./utils/i18n-mock.service";

View File

@@ -1,7 +1,7 @@
import { Directive, HostBinding, Input, Optional, Self } from "@angular/core";
import { Directive, ElementRef, HostBinding, Input, NgZone, Optional, Self } from "@angular/core";
import { NgControl, Validators } from "@angular/forms";
import { BitFormFieldControl } from "../form-field/form-field-control";
import { BitFormFieldControl, InputTypes } from "../form-field/form-field-control";
// Increments for each instance of this component
let nextId = 0;
@@ -41,14 +41,14 @@ export class BitInputDirective implements BitFormFieldControl {
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
get labelForId(): string {
return this.id;
}
@HostBinding("attr.aria-invalid") get ariaInvalid() {
return this.hasError ? true : undefined;
}
@HostBinding("attr.type") @Input() type?: InputTypes;
@HostBinding("attr.spellcheck") @Input() spellcheck?: boolean;
@HostBinding()
@Input()
get required() {
@@ -62,6 +62,10 @@ export class BitInputDirective implements BitFormFieldControl {
@Input() hasPrefix = false;
@Input() hasSuffix = false;
get labelForId(): string {
return this.id;
}
get hasError() {
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
}
@@ -70,5 +74,18 @@ export class BitInputDirective implements BitFormFieldControl {
const key = Object.keys(this.ngControl.errors)[0];
return [key, this.ngControl.errors[key]];
}
constructor(@Optional() @Self() private ngControl: NgControl) {}
constructor(
@Optional() @Self() private ngControl: NgControl,
private ngZone: NgZone,
private elementRef: ElementRef<HTMLInputElement>
) {}
focus() {
this.ngZone.runOutsideAngular(() => {
const end = this.elementRef.nativeElement.value.length;
this.elementRef.nativeElement.setSelectionRange(end, end);
this.elementRef.nativeElement.focus();
});
}
}

View File

@@ -6,49 +6,85 @@ const linkStyles: Record<LinkType, string[]> = {
primary: [
"!tw-text-primary-500",
"hover:!tw-text-primary-500",
"focus-visible:tw-ring-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:!tw-text-primary-500/60",
],
secondary: [
"!tw-text-main",
"hover:!tw-text-main",
"focus-visible:tw-ring-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:!tw-text-muted/60",
],
contrast: [
"!tw-text-contrast",
"hover:!tw-text-contrast",
"focus-visible:tw-ring-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"disabled:!tw-text-contrast/60",
],
};
@Directive({
selector: "button[bitLink], a[bitLink]",
})
export class LinkDirective {
@HostBinding("class") get classList() {
return [
"tw-font-semibold",
"tw-py-0.5",
"tw-px-0",
"tw-bg-transparent",
"tw-border-0",
"tw-border-none",
"tw-rounded",
"tw-transition",
"hover:tw-underline",
"hover:tw-decoration-1",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:tw-ring-2",
"focus-visible:tw-z-10",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
].concat(linkStyles[this.linkType] ?? []);
}
const commonStyles = [
"tw-leading-none",
"tw-p-0",
"tw-font-semibold",
"tw-bg-transparent",
"tw-border-0",
"tw-border-none",
"tw-rounded",
"tw-transition",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
// Workaround for html button tag not being able to be set to `display: inline`
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
// https://github.com/w3c/csswg-drafts/issues/3226
// Add `tw-inline`, add `tw-py-0.5` and use regular `tw-ring` if issue is fixed.
//
// 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-x-[0.1em]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring-2",
"focus-visible:before:tw-ring-text-contrast",
"focus-visible:tw-z-10",
];
@Directive()
abstract class LinkDirective {
@Input()
linkType: LinkType = "primary";
}
@Directive({
selector: "a[bitLink]",
})
export class AnchorLinkDirective extends LinkDirective {
@HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.125rem]"]
.concat(commonStyles)
.concat(linkStyles[this.linkType] ?? []);
}
}
@Directive({
selector: "button[bitLink]",
})
export class ButtonLinkDirective extends LinkDirective {
@HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.25rem]"]
.concat(commonStyles)
.concat(linkStyles[this.linkType] ?? []);
}
}

View File

@@ -1,11 +1,11 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { LinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
@NgModule({
imports: [CommonModule],
exports: [LinkDirective],
declarations: [LinkDirective],
exports: [AnchorLinkDirective, ButtonLinkDirective],
declarations: [AnchorLinkDirective, ButtonLinkDirective],
})
export class LinkModule {}

View File

@@ -1,10 +1,15 @@
import { Meta, Story } from "@storybook/angular";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { LinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
title: "Component Library/Link",
component: LinkDirective,
decorators: [
moduleMetadata({
imports: [LinkModule],
}),
],
argTypes: {
linkType: {
options: ["primary", "secondary", "contrast"],
@@ -19,25 +24,33 @@ export default {
},
} as Meta;
const ButtonTemplate: Story<LinkDirective> = (args: LinkDirective) => ({
const ButtonTemplate: Story<ButtonLinkDirective> = (args: ButtonLinkDirective) => ({
props: args,
template: `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-500': linkType === 'contrast' }">
<button bitLink [linkType]="linkType" class="tw-mb-2 tw-block">Button</button>
<button bitLink [linkType]="linkType" class="tw-mb-2 tw-block">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
Add Icon Button
</button>
<button bitLink [linkType]="linkType" class="tw-mb-2 tw-block">
Chevron Icon Button
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
<button bitLink [linkType]="linkType" class="tw-text-sm tw-block">Small Button</button>
<div class="tw-block tw-p-2">
<button bitLink [linkType]="linkType">Button</button>
</div>
<div class="tw-block tw-p-2">
<button bitLink [linkType]="linkType">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
Add Icon Button
</button>
</div>
<div class="tw-block tw-p-2">
<button bitLink [linkType]="linkType">
Chevron Icon Button
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
<div class="tw-block tw-p-2">
<button bitLink [linkType]="linkType" class="tw-text-sm">Small Button</button>
</div>
</div>
`,
});
const AnchorTemplate: Story<LinkDirective> = (args: LinkDirective) => ({
const AnchorTemplate: Story<AnchorLinkDirective> = (args: AnchorLinkDirective) => ({
props: args,
template: `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-500': linkType === 'contrast' }">
@@ -73,6 +86,20 @@ Anchors.args = {
linkType: "primary",
};
const InlineTemplate: Story = (args) => ({
props: args,
template: `
<span class="tw-text-main">
On the internet pargraphs often contain <a bitLink href="#">inline links</a>, but few know that <button bitLink>buttons</button> can be used for similar purposes.
</span>
`,
});
export const Inline = InlineTemplate.bind({});
Inline.args = {
linkType: "primary",
};
const DisabledTemplate: Story = (args) => ({
props: args,
template: `

View File

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

View File

@@ -0,0 +1,47 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
/**
* Base class used in `NavGroupComponent` and `NavItemComponent`
*/
@Directive()
export abstract class NavBaseComponent {
/**
* Text to display in main content
*/
@Input() text: string;
/**
* `aria-label` for main content
*/
@Input() ariaLabel: string;
/**
* Optional icon, e.g. `"bwi-collection"`
*/
@Input() icon: string;
/**
* Route to be passed to internal `routerLink`
*/
@Input() route: string | any[];
/**
* If this item is used within a tree, set `variant` to `"tree"`
*/
@Input() variant: "default" | "tree" = "default";
/**
* Depth level when nested inside of a `'tree'` variant
*/
@Input() treeDepth = 0;
/**
* If `true`, do not change styles when nav item is active.
*/
@Input() hideActiveStyles = false;
/**
* Fires when main content is clicked
*/
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
}

View File

@@ -0,0 +1 @@
<div class="tw-h-px tw-w-full tw-bg-secondary-300"></div>

View File

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

View File

@@ -0,0 +1,46 @@
<!-- This a higher order component that composes `NavItemComponent` -->
<bit-nav-item
[text]="text"
[icon]="icon"
[route]="route"
[variant]="variant"
(mainContentClicked)="toggle()"
[treeDepth]="treeDepth"
(mainContentClicked)="mainContentClicked.emit()"
[ariaLabel]="ariaLabel"
>
<ng-template #button>
<button
class="tw-ml-auto"
[bitIconButton]="
open ? 'bwi-chevron-up' : variant === 'tree' ? 'bwi-angle-right' : 'bwi-angle-down'
"
[buttonType]="'main'"
(click)="toggle($event)"
size="small"
[title]="'toggleCollapse' | i18n"
aria-haspopup="true"
[attr.aria-expanded]="open.toString()"
[attr.aria-controls]="contentId"
[attr.aria-label]="['toggleCollapse' | i18n, text].join(' ')"
></button>
</ng-template>
<!-- Show toggle to the left for trees otherwise to the right -->
<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>
</bit-nav-item>
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
<div
*ngIf="open"
[attr.id]="contentId"
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
role="group"
>
<ng-content></ng-content>
</div>

View File

@@ -0,0 +1,62 @@
import {
AfterContentInit,
Component,
ContentChildren,
EventEmitter,
Input,
Output,
QueryList,
} 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",
})
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
@ContentChildren(NavGroupComponent, {
descendants: true,
})
nestedGroups!: QueryList<NavGroupComponent>;
@ContentChildren(NavItemComponent, {
descendants: true,
})
nestedItems!: QueryList<NavItemComponent>;
/**
* UID for `[attr.aria-controls]`
*/
protected contentId = Math.random().toString(36).substring(2);
/**
* Is `true` if the expanded content is visible
*/
@Input()
open = false;
@Output()
openChange = new EventEmitter<boolean>();
protected toggle(event?: MouseEvent) {
event?.stopPropagation();
this.open = !this.open;
}
/**
* - For any nested NavGroupComponents or NavItemComponents, increment the `treeDepth` by 1.
*/
private initNestedStyles() {
if (this.variant !== "tree") {
return;
}
[...this.nestedGroups, ...this.nestedItems].forEach((navGroupOrItem) => {
navGroupOrItem.treeDepth += 1;
});
}
ngAfterContentInit(): void {
this.initNestedStyles();
}
}

View File

@@ -0,0 +1,74 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { SharedModule } from "../shared/shared.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { NavGroupComponent } from "./nav-group.component";
import { NavigationModule } from "./navigation.module";
export default {
title: "Component Library/Nav/Nav Group",
component: NavGroupComponent,
decorators: [
moduleMetadata({
imports: [SharedModule, RouterTestingModule, NavigationModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
submenu: "submenu",
toggleCollapse: "toggle collapse",
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
},
},
} as Meta;
export const Default: Story<NavGroupComponent> = (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>
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B"></bit-nav-item>
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
`,
});
export const Tree: Story<NavGroupComponent> = (args) => ({
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 childen" 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 childen, 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 childen, 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 childen" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
</bit-nav-group>
<bit-nav-item text="Level 1 - no childen" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
</bit-nav-group>
`,
});

View File

@@ -0,0 +1,79 @@
<div
class="tw-relative"
[ngClass]="[
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3',
fvwStyles$ | async
]"
>
<div
[ngStyle]="{
'padding-left': (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem'
}"
class="tw-relative tw-flex tw-items-center tw-pr-4"
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
>
<div
#slotStart
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*]:!tw-text-alt2 [&>*:hover]:!tw-border-text-alt2"
>
<ng-content select="[slot-start]"></ng-content>
</div>
<!-- Default content for #slotStart (for consistent sizing) -->
<div
*ngIf="slotStart.childElementCount === 0"
[ngClass]="{
'tw-w-0': variant !== 'tree'
}"
>
<button
class="tw-invisible"
[bitIconButton]="'bwi-angle-down'"
size="small"
aria-hidden="true"
></button>
</div>
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
<!-- 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>
</ng-template>
<!-- Show if a value was passed to `this.to` -->
<ng-template #isAnchor>
<!-- The `fvw` class passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="fvw tw-w-full tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap 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"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="rlaOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setActive($event)"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>
</ng-template>
<!-- Show if `this.to` is falsy -->
<ng-template #isButton>
<!-- Class field should match `#isAnchor` class field above -->
<button
class="fvw tw-w-full tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap 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"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</button>
</ng-template>
<div
class="tw-flex tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*]:!tw-text-alt2 [&>*:hover]:!tw-border-text-alt2"
>
<ng-content select="[slot-end]"></ng-content>
</div>
</div>
</div>

View File

@@ -0,0 +1,48 @@
import { Component, HostListener } from "@angular/core";
import { IsActiveMatchOptions } from "@angular/router";
import { BehaviorSubject, map } from "rxjs";
import { NavBaseComponent } from "./nav-base.component";
@Component({
selector: "bit-nav-item",
templateUrl: "./nav-item.component.html",
})
export class NavItemComponent extends NavBaseComponent {
/**
* Is `true` if `to` matches the current route
*/
private _active = false;
protected setActive(isActive: boolean) {
this._active = isActive;
}
protected get showActiveStyles() {
return this._active && !this.hideActiveStyles;
}
protected readonly rlaOptions: IsActiveMatchOptions = {
paths: "exact",
queryParams: "exact",
fragment: "ignored",
matrixParams: "ignored",
};
/**
* The design spec calls for the an outline to wrap the entire element when the template's anchor/button has :focus-visible.
* Usually, we would use :focus-within for this. However, that matches when a child element has :focus instead of :focus-visible.
*
* Currently, the browser does not have a pseudo selector that combines these two, e.g. :focus-visible-within (WICG/focus-visible#151)
* To make our own :focus-visible-within functionality, we use event delegation on the host and manually check if the focus target (denoted with the .fvw class) matches :focus-visible. We then map that state to some styles, so the entire component can have an outline.
*/
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" : ""))
);
@HostListener("focusin", ["$event.target"])
onFocusIn(target: HTMLElement) {
this.focusVisibleWithin$.next(target.matches(".fvw:focus-visible"));
}
@HostListener("focusout")
onFocusOut() {
this.focusVisibleWithin$.next(false);
}
}

View File

@@ -0,0 +1,93 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { IconButtonModule } from "../icon-button";
import { NavItemComponent } from "./nav-item.component";
import { NavigationModule } from "./navigation.module";
export default {
title: "Component Library/Nav/Nav Item",
component: NavItemComponent,
decorators: [
moduleMetadata({
declarations: [],
imports: [RouterTestingModule, IconButtonModule, NavigationModule],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
},
},
} as Meta;
const Template: Story<NavItemComponent> = (args: NavItemComponent) => ({
props: args,
template: `
<bit-nav-item text="${args.text}" [route]="['']" icon="${args.icon}"></bit-nav-item>
`,
});
export const Default = Template.bind({});
Default.args = {
text: "Hello World",
icon: "bwi-filter",
};
export const WithoutIcon = Template.bind({});
WithoutIcon.args = {
text: "Hello World",
icon: "",
};
export const WithoutRoute: Story<NavItemComponent> = (args: NavItemComponent) => ({
props: args,
template: `
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
`,
});
export const WithChildButtons: Story<NavItemComponent> = (args: NavItemComponent) => ({
props: args,
template: `
<bit-nav-item text="Hello World" [route]="['']" icon="bwi-collection">
<button
slot-start
class="tw-ml-auto"
[bitIconButton]="'bwi-clone'"
[buttonType]="'contrast'"
size="small"
aria-label="option 1"
></button>
<button
slot-end
class="tw-ml-auto"
[bitIconButton]="'bwi-pencil-square'"
[buttonType]="'contrast'"
size="small"
aria-label="option 2"
></button>
<button
slot-end
class="tw-ml-auto"
[bitIconButton]="'bwi-check'"
[buttonType]="'contrast'"
size="small"
aria-label="option 3"
></button>
</bit-nav-item>
`,
});
export const MultipleItemsWithDivider: Story<NavItemComponent> = (args: NavItemComponent) => ({
props: args,
template: `
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
<bit-nav-divider></bit-nav-divider>
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
`,
});

View File

@@ -0,0 +1,18 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { IconButtonModule } from "../icon-button/icon-button.module";
import { SharedModule } from "../shared/shared.module";
import { NavDividerComponent } from "./nav-divider.component";
import { NavGroupComponent } from "./nav-group.component";
import { NavItemComponent } from "./nav-item.component";
@NgModule({
imports: [CommonModule, SharedModule, IconButtonModule, OverlayModule, RouterModule],
declarations: [NavDividerComponent, NavGroupComponent, NavItemComponent],
exports: [NavDividerComponent, NavGroupComponent, NavItemComponent],
})
export class NavigationModule {}

View File

@@ -0,0 +1,3 @@
export * from "./radio-button.module";
export * from "./radio-button.component";
export * from "./radio-group.component";

View File

@@ -0,0 +1,14 @@
<bit-form-control inline>
<input
type="radio"
bitRadio
[id]="inputId"
[name]="name"
[disabled]="disabled"
[value]="value"
[checked]="selected"
(change)="onInputChange()"
(blur)="onBlur()"
/>
<bit-label><ng-content></ng-content></bit-label>
</bit-form-control>

View File

@@ -0,0 +1,79 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { I18nMockService } from "../utils/i18n-mock.service";
import { RadioButtonModule } from "./radio-button.module";
import { RadioGroupComponent } from "./radio-group.component";
describe("RadioButton", () => {
let mockGroupComponent: MockedButtonGroupComponent;
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let radioButton: HTMLInputElement;
beforeEach(waitForAsync(() => {
mockGroupComponent = new MockedButtonGroupComponent();
TestBed.configureTestingModule({
imports: [RadioButtonModule],
declarations: [TestApp],
providers: [
{ provide: RadioGroupComponent, useValue: mockGroupComponent },
{ provide: I18nService, useValue: new I18nMockService({}) },
],
});
TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
testAppComponent = fixture.debugElement.componentInstance;
radioButton = fixture.debugElement.query(By.css("input[type=radio]")).nativeElement;
}));
it("should emit value when clicking on radio button", () => {
testAppComponent.value = "value";
fixture.detectChanges();
radioButton.click();
fixture.detectChanges();
expect(mockGroupComponent.onInputChange).toHaveBeenCalledWith("value");
});
it("should check radio button when selected matches value", () => {
testAppComponent.value = "value";
fixture.detectChanges();
mockGroupComponent.selected = "value";
fixture.detectChanges();
expect(radioButton.checked).toBe(true);
});
it("should not check radio button when selected does not match value", () => {
testAppComponent.value = "value";
fixture.detectChanges();
mockGroupComponent.selected = "nonMatchingValue";
fixture.detectChanges();
expect(radioButton.checked).toBe(false);
});
});
class MockedButtonGroupComponent implements Partial<RadioGroupComponent> {
onInputChange = jest.fn();
selected = null;
}
@Component({
selector: "test-app",
template: ` <bit-radio-button [value]="value">Element</bit-radio-button>`,
})
class TestApp {
value?: string;
}

View File

@@ -0,0 +1,40 @@
import { Component, HostBinding, Input } from "@angular/core";
import { RadioGroupComponent } from "./radio-group.component";
let nextId = 0;
@Component({
selector: "bit-radio-button",
templateUrl: "radio-button.component.html",
})
export class RadioButtonComponent {
@HostBinding("attr.id") @Input() id = `bit-radio-button-${nextId++}`;
@Input() value: unknown;
constructor(private groupComponent: RadioGroupComponent) {}
get inputId() {
return `${this.id}-input`;
}
get name() {
return this.groupComponent.name;
}
get selected() {
return this.groupComponent.selected === this.value;
}
get disabled() {
return this.groupComponent.disabled;
}
protected onInputChange() {
this.groupComponent.onInputChange(this.value);
}
protected onBlur() {
this.groupComponent.onBlur();
}
}

View File

@@ -0,0 +1,15 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormControlModule } from "../form-control";
import { RadioButtonComponent } from "./radio-button.component";
import { RadioGroupComponent } from "./radio-group.component";
import { RadioInputComponent } from "./radio-input.component";
@NgModule({
imports: [CommonModule, FormControlModule],
declarations: [RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
exports: [FormControlModule, RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
})
export class RadioButtonModule {}

View File

@@ -0,0 +1,107 @@
import { Component, Input } from "@angular/core";
import { FormsModule, ReactiveFormsModule, FormBuilder } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { I18nMockService } from "../utils/i18n-mock.service";
import { RadioButtonModule } from "./radio-button.module";
const template = `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-label *ngIf="label">Group of radio buttons</bit-label>
<bit-radio-button [value]="TestValue.First" id="radio-first">First</bit-radio-button>
<bit-radio-button [value]="TestValue.Second" id="radio-second">Second</bit-radio-button>
<bit-radio-button [value]="TestValue.Third" id="radio-third">Third</bit-radio-button>
</bit-radio-group>
</form>`;
enum TestValue {
First,
Second,
Third,
}
@Component({
selector: "app-example",
template,
})
class ExampleComponent {
protected TestValue = TestValue;
protected formObj = this.formBuilder.group({
radio: TestValue.First,
});
@Input() label: boolean;
@Input() set selected(value: TestValue) {
this.formObj.patchValue({ radio: value });
}
@Input() set disabled(disable: boolean) {
if (disable) {
this.formObj.disable();
} else {
this.formObj.enable();
}
}
constructor(private formBuilder: FormBuilder) {}
}
export default {
title: "Component Library/Form/Radio Button",
component: ExampleComponent,
decorators: [
moduleMetadata({
declarations: [ExampleComponent],
imports: [FormsModule, ReactiveFormsModule, RadioButtonModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
required: "required",
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=3930%3A16850&t=xXPx6GJYsJfuMQPE-4",
},
},
args: {
selected: TestValue.First,
disabled: false,
label: true,
},
argTypes: {
selected: {
options: [TestValue.First, TestValue.Second, TestValue.Third],
control: {
type: "inline-radio",
labels: {
[TestValue.First]: "First",
[TestValue.Second]: "Second",
[TestValue.Third]: "Third",
},
},
},
},
} as Meta;
const DefaultTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
props: args,
template: `<app-example [selected]="selected" [disabled]="disabled" [label]="label"></app-example>`,
});
export const Default = DefaultTemplate.bind({});

View File

@@ -0,0 +1,14 @@
<ng-container *ngIf="label">
<fieldset>
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-semibold tw-text-main">
<ng-content select="bit-label"></ng-content>
</legend>
<ng-container *ngTemplateOutlet="content"></ng-container>
</fieldset>
</ng-container>
<ng-container *ngIf="!label">
<ng-container *ngTemplateOutlet="content"></ng-container>
</ng-container>
<ng-template #content><ng-content></ng-content></ng-template>

View File

@@ -0,0 +1,79 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { FormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { I18nMockService } from "../utils/i18n-mock.service";
import { RadioButtonComponent } from "./radio-button.component";
import { RadioButtonModule } from "./radio-button.module";
describe("RadioGroupComponent", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let buttonElements: RadioButtonComponent[];
let radioButtons: HTMLInputElement[];
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [FormsModule, RadioButtonModule],
declarations: [TestApp],
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
});
TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
testAppComponent = fixture.debugElement.componentInstance;
buttonElements = fixture.debugElement
.queryAll(By.css("bit-radio-button"))
.map((e) => e.componentInstance);
radioButtons = fixture.debugElement
.queryAll(By.css("input[type=radio]"))
.map((e) => e.nativeElement);
fixture.detectChanges();
}));
it("should select second element when setting selected to second", async () => {
testAppComponent.selected = "second";
fixture.detectChanges();
await fixture.whenStable();
expect(buttonElements[1].selected).toBe(true);
});
it("should not select second element when setting selected to third", async () => {
testAppComponent.selected = "third";
fixture.detectChanges();
await fixture.whenStable();
expect(buttonElements[1].selected).toBe(false);
});
it("should emit new value when changing selection by clicking on radio button", async () => {
testAppComponent.selected = "first";
fixture.detectChanges();
await fixture.whenStable();
radioButtons[1].click();
expect(testAppComponent.selected).toBe("second");
});
});
@Component({
selector: "test-app",
template: `
<bit-radio-group [(ngModel)]="selected">
<bit-radio-button value="first">First</bit-radio-button>
<bit-radio-button value="second">Second</bit-radio-button>
<bit-radio-button value="third">Third</bit-radio-button>
</bit-radio-group>
`,
})
class TestApp {
selected?: string;
}

View File

@@ -0,0 +1,63 @@
import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core";
import { ControlValueAccessor, NgControl } from "@angular/forms";
import { BitLabel } from "../form-control/label.directive";
let nextId = 0;
@Component({
selector: "bit-radio-group",
templateUrl: "radio-group.component.html",
})
export class RadioGroupComponent implements ControlValueAccessor {
selected: unknown;
disabled = false;
private _name?: string;
@Input() get name() {
return this._name ?? this.ngControl?.name?.toString();
}
set name(value: string) {
this._name = value;
}
@HostBinding("attr.role") role = "radiogroup";
@HostBinding("attr.id") @Input() id = `bit-radio-group-${nextId++}`;
@ContentChild(BitLabel) protected label: BitLabel;
constructor(@Optional() @Self() private ngControl?: NgControl) {
if (ngControl != null) {
ngControl.valueAccessor = this;
}
}
// ControlValueAccessor
onChange: (value: unknown) => void;
onTouched: () => void;
writeValue(value: boolean): void {
this.selected = value;
}
registerOnChange(fn: (value: unknown) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onInputChange(value: unknown) {
this.selected = value;
this.onChange(this.selected);
}
onBlur() {
this.onTouched();
}
}

View File

@@ -0,0 +1,99 @@
import { Component, HostBinding, Input, Optional, Self } from "@angular/core";
import { NgControl, Validators } from "@angular/forms";
import { BitFormControlAbstraction } from "../form-control";
let nextId = 0;
@Component({
selector: "input[type=radio][bitRadio]",
template: "",
providers: [{ provide: BitFormControlAbstraction, useExisting: RadioInputComponent }],
})
export class RadioInputComponent implements BitFormControlAbstraction {
@HostBinding("attr.id") @Input() id = `bit-radio-input-${nextId++}`;
@HostBinding("class")
protected inputClasses = [
"tw-appearance-none",
"tw-outline-none",
"tw-relative",
"tw-transition",
"tw-cursor-pointer",
"tw-inline-block",
"tw-rounded-full",
"tw-border",
"tw-border-solid",
"tw-border-secondary-500",
"tw-w-3.5",
"tw-h-3.5",
"tw-mr-1.5",
"tw-bottom-[-1px]", // Fix checkbox looking off-center
"hover:tw-border-2",
"[&>label:hover]:tw-border-2",
"before:tw-content-['']",
"before:tw-transition",
"before:tw-block",
"before:tw-absolute",
"before:tw-rounded-full",
"before:tw-inset-[2px]",
"focus-visible:tw-ring-2",
"focus-visible:tw-ring-offset-2",
"focus-visible:tw-ring-primary-700",
"disabled:tw-cursor-auto",
"disabled:tw-border",
"disabled:tw-bg-secondary-100",
"checked:tw-bg-text-contrast",
"checked:tw-border-primary-500",
"checked:hover:tw-border",
"checked:hover:tw-border-primary-700",
"checked:hover:before:tw-bg-primary-700",
"[&>label:hover]:checked:tw-bg-primary-700",
"[&>label:hover]:checked:tw-border-primary-700",
"checked:before:tw-bg-primary-500",
"checked:disabled:tw-border-secondary-100",
"checked:disabled:tw-bg-secondary-100",
"checked:disabled:before:tw-bg-text-muted",
];
constructor(@Optional() @Self() private ngControl?: NgControl) {}
@HostBinding()
@Input()
get disabled() {
return this._disabled ?? this.ngControl?.disabled ?? false;
}
set disabled(value: any) {
this._disabled = value != null && value !== false;
}
private _disabled: boolean;
@Input()
get required() {
return (
this._required ?? this.ngControl?.control?.hasValidator(Validators.requiredTrue) ?? false
);
}
set required(value: any) {
this._required = value != null && value !== false;
}
private _required: boolean;
get hasError() {
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
}
get error(): [string, any] {
const key = Object.keys(this.ngControl.errors)[0];
return [key, this.ngControl.errors[key]];
}
}

View File

@@ -21,6 +21,8 @@ export const Table = (args) => (
{Row("background")}
{Row("background-alt")}
{Row("background-alt2")}
{Row("background-alt3")}
{Row("background-alt4")}
</tbody>
<tbody>
{Row("primary-300")}

View File

@@ -24,7 +24,7 @@ export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestr
fragment: "ignored",
};
@Input() route: string;
@Input() route: string | any[];
@Input() disabled = false;
@HostListener("keydown", ["$event"]) onKeyDown(event: KeyboardEvent) {

View File

@@ -4,6 +4,8 @@
--color-background: 255 255 255;
--color-background-alt: 251 251 251;
--color-background-alt2: 23 92 219;
--color-background-alt3: 18 82 163;
--color-background-alt4: 13 60 119;
--color-primary-300: 103 149 232;
--color-primary-500: 23 93 220;
@@ -45,6 +47,8 @@
--color-background: 31 36 46;
--color-background-alt: 22 28 38;
--color-background-alt2: 47 52 61;
--color-background-alt3: 47 52 61;
--color-background-alt4: 16 18 21;
--color-primary-300: 23 93 220;
--color-primary-500: 106 153 240;

View File

@@ -56,6 +56,8 @@ module.exports = {
DEFAULT: rgba("--color-background"),
alt: rgba("--color-background-alt"),
alt2: rgba("--color-background-alt2"),
alt3: rgba("--color-background-alt3"),
alt4: rgba("--color-background-alt4"),
},
},
textColor: {
@@ -83,6 +85,9 @@ module.exports = {
"50vw": "50vw",
"75vw": "75vw",
},
minWidth: {
52: "13rem",
},
maxWidth: ({ theme }) => ({
...theme("width"),
"90vw": "90vw",