diff --git a/libs/components/src/checkbox/checkbox.component.ts b/libs/components/src/checkbox/checkbox.component.ts
new file mode 100644
index 00000000000..07b2066e8a4
--- /dev/null
+++ b/libs/components/src/checkbox/checkbox.component.ts
@@ -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]];
+ }
+}
diff --git a/libs/components/src/checkbox/checkbox.module.ts b/libs/components/src/checkbox/checkbox.module.ts
new file mode 100644
index 00000000000..d03b9cf5050
--- /dev/null
+++ b/libs/components/src/checkbox/checkbox.module.ts
@@ -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 {}
diff --git a/libs/components/src/checkbox/checkbox.stories.ts b/libs/components/src/checkbox/checkbox.stories.ts
new file mode 100644
index 00000000000..c7bb4bf80e2
--- /dev/null
+++ b/libs/components/src/checkbox/checkbox.stories.ts
@@ -0,0 +1,104 @@
+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 = `
+
`;
+
+@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",
+ component: ExampleComponent,
+ 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",
+ },
+ },
+ args: {
+ checked: false,
+ disabled: false,
+ },
+} as Meta;
+
+const DefaultTemplate: Story = (args: ExampleComponent) => ({
+ props: args,
+ template: ``,
+});
+
+export const Default = DefaultTemplate.bind({});
+
+const CustomTemplate: Story = (args) => ({
+ props: args,
+ template: `
+
+
+
+
+
+ `,
+});
+
+export const Custom = CustomTemplate.bind({});
diff --git a/libs/components/src/checkbox/index.ts b/libs/components/src/checkbox/index.ts
new file mode 100644
index 00000000000..cec5a17a67a
--- /dev/null
+++ b/libs/components/src/checkbox/index.ts
@@ -0,0 +1 @@
+export * from "./checkbox.module";
diff --git a/libs/components/src/form-control/form-control.abstraction.ts b/libs/components/src/form-control/form-control.abstraction.ts
new file mode 100644
index 00000000000..6c65c6740b6
--- /dev/null
+++ b/libs/components/src/form-control/form-control.abstraction.ts
@@ -0,0 +1,6 @@
+export abstract class BitFormControlAbstraction {
+ disabled: boolean;
+ required: boolean;
+ hasError: boolean;
+ error: [string, any];
+}
diff --git a/libs/components/src/form-control/form-control.component.html b/libs/components/src/form-control/form-control.component.html
new file mode 100644
index 00000000000..9e31bc9ec40
--- /dev/null
+++ b/libs/components/src/form-control/form-control.component.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ {{ displayError }}
+
diff --git a/libs/components/src/form-control/form-control.component.ts b/libs/components/src/form-control/form-control.component.ts
new file mode 100644
index 00000000000..3dc080f71b9
--- /dev/null
+++ b/libs/components/src/form-control/form-control.component.ts
@@ -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;
+ }
+ }
+}
diff --git a/libs/components/src/form-control/form-control.module.ts b/libs/components/src/form-control/form-control.module.ts
new file mode 100644
index 00000000000..8a0377582c2
--- /dev/null
+++ b/libs/components/src/form-control/form-control.module.ts
@@ -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 {}
diff --git a/libs/components/src/form-field/hint.component.ts b/libs/components/src/form-control/hint.component.ts
similarity index 100%
rename from libs/components/src/form-field/hint.component.ts
rename to libs/components/src/form-control/hint.component.ts
diff --git a/libs/components/src/form-control/index.ts b/libs/components/src/form-control/index.ts
new file mode 100644
index 00000000000..0b30cb68857
--- /dev/null
+++ b/libs/components/src/form-control/index.ts
@@ -0,0 +1,3 @@
+export * from "./form-control.module";
+export * from "./form-control.abstraction";
+export * from "./form-control.component";
diff --git a/libs/components/src/form-field/label.directive.ts b/libs/components/src/form-control/label.directive.ts
similarity index 100%
rename from libs/components/src/form-field/label.directive.ts
rename to libs/components/src/form-control/label.directive.ts
diff --git a/libs/components/src/form-field/form-field.component.ts b/libs/components/src/form-field/form-field.component.ts
index 9fbaeb688ca..9553641188e 100644
--- a/libs/components/src/form-field/form-field.component.ts
+++ b/libs/components/src/form-field/form-field.component.ts
@@ -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";
diff --git a/libs/components/src/form-field/form-field.module.ts b/libs/components/src/form-field/form-field.module.ts
index cc18cf4b77f..989375167d4 100644
--- a/libs/components/src/form-field/form-field.module.ts
+++ b/libs/components/src/form-field/form-field.module.ts
@@ -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,20 +10,16 @@ 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],
+ imports: [SharedModule, FormControlModule, InputModule, MultiSelectModule],
declarations: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
- BitHintComponent,
- BitLabel,
BitPasswordInputToggleDirective,
BitPrefixDirective,
BitSuffixDirective,
@@ -31,13 +28,12 @@ import { BitSuffixDirective } from "./suffix.directive";
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
- BitHintComponent,
BitInputDirective,
- BitLabel,
BitPasswordInputToggleDirective,
BitPrefixDirective,
BitSuffixDirective,
MultiSelectComponent,
+ FormControlModule,
],
})
export class FormFieldModule {}
diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts
index 1683f515e28..329f71c0ff5 100644
--- a/libs/components/src/form-field/form-field.stories.ts
+++ b/libs/components/src/form-field/form-field.stories.ts
@@ -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
diff --git a/libs/components/src/form/form.stories.ts b/libs/components/src/form/form.stories.ts
new file mode 100644
index 00000000000..050ea5e2c8f
--- /dev/null
+++ b/libs/components/src/form/form.stories.ts
@@ -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: `
+
+ `,
+});
+
+export const FullExample = FullExampleTemplate.bind({});
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts
index 8abb6bade3d..abae5395575 100644
--- a/libs/components/src/index.ts
+++ b/libs/components/src/index.ts
@@ -4,6 +4,7 @@ export * from "./badge";
export * from "./banner";
export * from "./button";
export * from "./callout";
+export * from "./checkbox";
export * from "./dialog";
export * from "./form-field";
export * from "./icon-button";
diff --git a/libs/components/src/radio-button/index.ts b/libs/components/src/radio-button/index.ts
new file mode 100644
index 00000000000..b832cf2c5c9
--- /dev/null
+++ b/libs/components/src/radio-button/index.ts
@@ -0,0 +1,3 @@
+export * from "./radio-button.module";
+export * from "./radio-button.component";
+export * from "./radio-group.component";
diff --git a/libs/components/src/radio-button/radio-button.component.html b/libs/components/src/radio-button/radio-button.component.html
new file mode 100644
index 00000000000..6ecbbff862d
--- /dev/null
+++ b/libs/components/src/radio-button/radio-button.component.html
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/libs/components/src/radio-button/radio-button.component.spec.ts b/libs/components/src/radio-button/radio-button.component.spec.ts
new file mode 100644
index 00000000000..1d2759c1af1
--- /dev/null
+++ b/libs/components/src/radio-button/radio-button.component.spec.ts
@@ -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;
+ 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 {
+ onInputChange = jest.fn();
+ selected = null;
+}
+
+@Component({
+ selector: "test-app",
+ template: ` Element`,
+})
+class TestApp {
+ value?: string;
+}
diff --git a/libs/components/src/radio-button/radio-button.component.ts b/libs/components/src/radio-button/radio-button.component.ts
new file mode 100644
index 00000000000..32aa6e5577a
--- /dev/null
+++ b/libs/components/src/radio-button/radio-button.component.ts
@@ -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();
+ }
+}
diff --git a/libs/components/src/radio-button/radio-button.module.ts b/libs/components/src/radio-button/radio-button.module.ts
new file mode 100644
index 00000000000..11b5bc3732a
--- /dev/null
+++ b/libs/components/src/radio-button/radio-button.module.ts
@@ -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 {}
diff --git a/libs/components/src/radio-button/radio-button.stories.ts b/libs/components/src/radio-button/radio-button.stories.ts
new file mode 100644
index 00000000000..d2689e84d2c
--- /dev/null
+++ b/libs/components/src/radio-button/radio-button.stories.ts
@@ -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 = `
+ `;
+
+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 = (args: ExampleComponent) => ({
+ props: args,
+ template: ``,
+});
+
+export const Default = DefaultTemplate.bind({});
diff --git a/libs/components/src/radio-button/radio-group.component.html b/libs/components/src/radio-button/radio-group.component.html
new file mode 100644
index 00000000000..2931020b2b8
--- /dev/null
+++ b/libs/components/src/radio-button/radio-group.component.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/libs/components/src/radio-button/radio-group.component.spec.ts b/libs/components/src/radio-button/radio-group.component.spec.ts
new file mode 100644
index 00000000000..d9df8e2d706
--- /dev/null
+++ b/libs/components/src/radio-button/radio-group.component.spec.ts
@@ -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;
+ 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: `
+
+ First
+ Second
+ Third
+
+ `,
+})
+class TestApp {
+ selected?: string;
+}
diff --git a/libs/components/src/radio-button/radio-group.component.ts b/libs/components/src/radio-button/radio-group.component.ts
new file mode 100644
index 00000000000..4b882303941
--- /dev/null
+++ b/libs/components/src/radio-button/radio-group.component.ts
@@ -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();
+ }
+}
diff --git a/libs/components/src/radio-button/radio-input.component.ts b/libs/components/src/radio-button/radio-input.component.ts
new file mode 100644
index 00000000000..bc767add6e5
--- /dev/null
+++ b/libs/components/src/radio-button/radio-input.component.ts
@@ -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]];
+ }
+}