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 = ` +
+ + + Click me + +
`; + +@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: ` +
+ + Name + + + + + Email + + + + + Agree to terms + + Required for the service to work properly + + + + Subscribe to updates? + Yes + No + Decide later + + + +
+ `, +}); + +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 = ` +
+ + Group of radio buttons + First + Second + Third + +
`; + +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]]; + } +}