From 78d2f957d5d13bf0abfdba46aeb3d9714db608ce Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 2 Jun 2022 09:34:13 +0200 Subject: [PATCH] [CL-23] Forms (#786) --- components/src/button/button.component.ts | 1 + .../src/form-field/error-summary.component.ts | 39 ++++ .../src/form-field/error-summary.stories.ts | 78 +++++++ components/src/form-field/error.component.ts | 38 ++++ .../src/form-field/form-field.component.html | 17 ++ .../src/form-field/form-field.component.ts | 53 +++++ .../src/form-field/form-field.module.ts | 54 +++++ .../src/form-field/form-field.stories.ts | 212 ++++++++++++++++++ components/src/form-field/hint.component.ts | 14 ++ components/src/form-field/index.ts | 2 + components/src/form-field/label.directive.ts | 6 + components/src/form-field/prefix.directive.ts | 28 +++ components/src/form-field/suffix.directive.ts | 18 ++ components/src/index.ts | 1 + components/src/input/input.directive.ts | 65 ++++++ components/src/input/input.module.ts | 11 + components/tsconfig.json | 3 +- 17 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 components/src/form-field/error-summary.component.ts create mode 100644 components/src/form-field/error-summary.stories.ts create mode 100644 components/src/form-field/error.component.ts create mode 100644 components/src/form-field/form-field.component.html create mode 100644 components/src/form-field/form-field.component.ts create mode 100644 components/src/form-field/form-field.module.ts create mode 100644 components/src/form-field/form-field.stories.ts create mode 100644 components/src/form-field/hint.component.ts create mode 100644 components/src/form-field/index.ts create mode 100644 components/src/form-field/label.directive.ts create mode 100644 components/src/form-field/prefix.directive.ts create mode 100644 components/src/form-field/suffix.directive.ts create mode 100644 components/src/input/input.directive.ts create mode 100644 components/src/input/input.module.ts diff --git a/components/src/button/button.component.ts b/components/src/button/button.component.ts index 172be48b45..49f4e678d0 100644 --- a/components/src/button/button.component.ts +++ b/components/src/button/button.component.ts @@ -74,6 +74,7 @@ export class ButtonComponent implements OnInit, OnChanges { "focus:tw-ring", "focus:tw-ring-offset-2", "focus:tw-ring-primary-700", + "focus:tw-z-10", this.block ? "tw-w-full tw-block" : "tw-inline-block", buttonStyles[this.buttonType ?? "secondary"], ]; diff --git a/components/src/form-field/error-summary.component.ts b/components/src/form-field/error-summary.component.ts new file mode 100644 index 0000000000..fd359cc153 --- /dev/null +++ b/components/src/form-field/error-summary.component.ts @@ -0,0 +1,39 @@ +import { Component, Input } from "@angular/core"; +import { AbstractControl, FormGroup } from "@angular/forms"; + +@Component({ + selector: "bit-error-summary", + template: ` + {{ "fieldsNeedAttention" | i18n: errorString }} + `, + host: { + class: "tw-block tw-text-danger tw-mt-2", + "aria-live": "assertive", + }, +}) +export class BitErrorSummary { + @Input() + formGroup: FormGroup; + + get errorCount(): number { + return this.getErrorCount(this.formGroup); + } + + get errorString() { + return this.errorCount.toString(); + } + + private getErrorCount(form: FormGroup): number { + return Object.values(form.controls).reduce((acc: number, control: AbstractControl) => { + if (control instanceof FormGroup) { + return acc + this.getErrorCount(control); + } + + if (control.errors == null) { + return acc; + } + + return acc + Object.keys(control.errors).length; + }, 0); + } +} diff --git a/components/src/form-field/error-summary.stories.ts b/components/src/form-field/error-summary.stories.ts new file mode 100644 index 0000000000..f60038da08 --- /dev/null +++ b/components/src/form-field/error-summary.stories.ts @@ -0,0 +1,78 @@ +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { InputModule } from "src/input/input.module"; +import { I18nMockService } from "src/utils/i18n-mock.service"; + +import { I18nService } from "jslib-common/abstractions/i18n.service"; + +import { ButtonModule } from "../button"; + +import { BitFormFieldComponent } from "./form-field.component"; +import { FormFieldModule } from "./form-field.module"; + +export default { + title: "Jslib/Form Error Summary", + component: BitFormFieldComponent, + decorators: [ + moduleMetadata({ + imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + required: "required", + inputRequired: "Input is required.", + inputEmail: "Input is not an email-address.", + fieldsNeedAttention: "$COUNT$ field(s) above need your attention.", + }); + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689", + }, + }, +} as Meta; + +const fb = new FormBuilder(); + +const formObj = fb.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email]], +}); + +function submit() { + formObj.markAllAsTouched(); +} + +const Template: Story = (args: BitFormFieldComponent) => ({ + props: { + formObj: formObj, + submit: submit, + ...args, + }, + template: ` +
+ + Name + + + + + Email + + + + + +
+ `, +}); + +export const Default = Template.bind({}); +Default.props = {}; diff --git a/components/src/form-field/error.component.ts b/components/src/form-field/error.component.ts new file mode 100644 index 0000000000..72846307ac --- /dev/null +++ b/components/src/form-field/error.component.ts @@ -0,0 +1,38 @@ +import { Component, HostBinding, Input } from "@angular/core"; + +import { I18nService } from "jslib-common/abstractions/i18n.service"; + +// Increments for each instance of this component +let nextId = 0; + +@Component({ + selector: "bit-error", + template: ` {{ displayError }}`, + host: { + class: "tw-block tw-mt-1 tw-text-danger", + "aria-live": "assertive", + }, +}) +export class BitErrorComponent { + @HostBinding() id = `bit-error-${nextId++}`; + + @Input() error: [string, any]; + + constructor(private i18nService: I18nService) {} + + get displayError() { + switch (this.error[0]) { + case "required": + return this.i18nService.t("inputRequired"); + case "email": + return this.i18nService.t("inputEmail"); + default: + // Attempt to show a custom error message. + if (this.error[1]?.message) { + return this.error[1]?.message; + } + + return this.error; + } + } +} diff --git a/components/src/form-field/form-field.component.html b/components/src/form-field/form-field.component.html new file mode 100644 index 0000000000..00c844c336 --- /dev/null +++ b/components/src/form-field/form-field.component.html @@ -0,0 +1,17 @@ + +
+
+ +
+ +
+ +
+
+ + + + diff --git a/components/src/form-field/form-field.component.ts b/components/src/form-field/form-field.component.ts new file mode 100644 index 0000000000..29c4ba369f --- /dev/null +++ b/components/src/form-field/form-field.component.ts @@ -0,0 +1,53 @@ +import { + AfterContentChecked, + Component, + ContentChild, + ContentChildren, + QueryList, + ViewChild, +} from "@angular/core"; + +import { BitInputDirective } from "../input/input.directive"; + +import { BitErrorComponent } from "./error.component"; +import { BitHintComponent } from "./hint.component"; +import { BitPrefixDirective } from "./prefix.directive"; +import { BitSuffixDirective } from "./suffix.directive"; + +@Component({ + selector: "bit-form-field", + templateUrl: "./form-field.component.html", + host: { + class: "tw-mb-6 tw-block", + }, +}) +export class BitFormFieldComponent implements AfterContentChecked { + @ContentChild(BitInputDirective) input: BitInputDirective; + @ContentChild(BitHintComponent) hint: BitHintComponent; + + @ViewChild(BitErrorComponent) error: BitErrorComponent; + + @ContentChildren(BitPrefixDirective) prefixChildren: QueryList; + @ContentChildren(BitSuffixDirective) suffixChildren: QueryList; + + ngAfterContentChecked(): void { + this.input.hasPrefix = this.prefixChildren.length > 0; + this.input.hasSuffix = this.suffixChildren.length > 0; + + this.prefixChildren.forEach((prefix) => { + prefix.first = prefix == this.prefixChildren.first; + }); + + this.suffixChildren.forEach((suffix) => { + suffix.last = suffix == this.suffixChildren.last; + }); + + if (this.error) { + this.input.ariaDescribedBy = this.error.id; + } else if (this.hint) { + this.input.ariaDescribedBy = this.hint.id; + } else { + this.input.ariaDescribedBy = undefined; + } + } +} diff --git a/components/src/form-field/form-field.module.ts b/components/src/form-field/form-field.module.ts new file mode 100644 index 0000000000..323d5a5269 --- /dev/null +++ b/components/src/form-field/form-field.module.ts @@ -0,0 +1,54 @@ +import { CommonModule } from "@angular/common"; +import { NgModule, Pipe, PipeTransform } from "@angular/core"; + +import { I18nService } from "jslib-common/abstractions/i18n.service"; + +import { BitInputDirective } from "../input/input.directive"; +import { InputModule } from "../input/input.module"; + +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 { BitPrefixDirective } from "./prefix.directive"; +import { BitSuffixDirective } from "./suffix.directive"; + +/** + * Temporarily duplicate this pipe + */ +@Pipe({ + name: "i18n", +}) +export class I18nPipe implements PipeTransform { + constructor(private i18nService: I18nService) {} + + transform(id: string, p1?: string, p2?: string, p3?: string): string { + return this.i18nService.t(id, p1, p2, p3); + } +} + +@NgModule({ + imports: [CommonModule, InputModule], + exports: [ + BitErrorComponent, + BitErrorSummary, + BitFormFieldComponent, + BitHintComponent, + BitInputDirective, + BitLabel, + BitPrefixDirective, + BitSuffixDirective, + ], + declarations: [ + BitErrorComponent, + BitErrorSummary, + BitFormFieldComponent, + BitHintComponent, + BitLabel, + BitPrefixDirective, + BitSuffixDirective, + I18nPipe, + ], +}) +export class FormFieldModule {} diff --git a/components/src/form-field/form-field.stories.ts b/components/src/form-field/form-field.stories.ts new file mode 100644 index 0000000000..54881314c8 --- /dev/null +++ b/components/src/form-field/form-field.stories.ts @@ -0,0 +1,212 @@ +import { + AbstractControl, + FormBuilder, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators, +} from "@angular/forms"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { InputModule } from "src/input/input.module"; +import { I18nMockService } from "src/utils/i18n-mock.service"; + +import { I18nService } from "jslib-common/abstractions/i18n.service"; + +import { ButtonModule } from "../button"; + +import { BitFormFieldComponent } from "./form-field.component"; +import { FormFieldModule } from "./form-field.module"; + +export default { + title: "Jslib/Form Field", + component: BitFormFieldComponent, + decorators: [ + moduleMetadata({ + imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule], + 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/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689", + }, + }, +} as Meta; + +const fb = new FormBuilder(); +const formObj = fb.group({ + test: [""], + required: ["", [Validators.required]], +}); + +const defaultFormObj = fb.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]], +}); + +// 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; + }; +} + +function submit() { + defaultFormObj.markAllAsTouched(); +} + +const Template: Story = (args: BitFormFieldComponent) => ({ + props: { + formObj: defaultFormObj, + submit: submit, + ...args, + }, + template: ` +
+ + Name + + + + + Email + + + + +
+ `, +}); + +export const Default = Template.bind({}); +Default.props = {}; + +const RequiredTemplate: Story = (args: BitFormFieldComponent) => ({ + props: { + formObj: formObj, + ...args, + }, + template: ` + + Label + + + + + FormControl + + + `, +}); + +export const Required = RequiredTemplate.bind({}); +Required.props = {}; + +const HintTemplate: Story = (args: BitFormFieldComponent) => ({ + props: { + formObj: formObj, + ...args, + }, + template: ` + + FormControl + + Long hint text + + `, +}); + +export const Hint = HintTemplate.bind({}); +Required.props = {}; + +const DisabledTemplate: Story = (args: BitFormFieldComponent) => ({ + props: args, + template: ` + + Label + + + `, +}); + +export const Disabled = DisabledTemplate.bind({}); +Disabled.args = {}; + +const GroupTemplate: Story = (args: BitFormFieldComponent) => ({ + props: args, + template: ` + + Label + + $ + USD + + `, +}); + +export const InputGroup = GroupTemplate.bind({}); +InputGroup.args = {}; + +const ButtonGroupTemplate: Story = (args: BitFormFieldComponent) => ({ + props: args, + template: ` + + Label + + + + + + + `, +}); + +export const ButtonInputGroup = ButtonGroupTemplate.bind({}); +ButtonInputGroup.args = {}; + +const SelectTemplate: Story = (args: BitFormFieldComponent) => ({ + props: args, + template: ` + + Label + + + `, +}); + +export const Select = SelectTemplate.bind({}); +Select.args = {}; + +const TextareaTemplate: Story = (args: BitFormFieldComponent) => ({ + props: args, + template: ` + + Textarea + + + `, +}); + +export const Textarea = TextareaTemplate.bind({}); +Textarea.args = {}; diff --git a/components/src/form-field/hint.component.ts b/components/src/form-field/hint.component.ts new file mode 100644 index 0000000000..59d01a89da --- /dev/null +++ b/components/src/form-field/hint.component.ts @@ -0,0 +1,14 @@ +import { Directive, HostBinding } from "@angular/core"; + +// Increments for each instance of this component +let nextId = 0; + +@Directive({ + selector: "bit-hint", + host: { + class: "tw-text-muted tw-inline-block tw-mt-1", + }, +}) +export class BitHintComponent { + @HostBinding() id = `bit-hint-${nextId++}`; +} diff --git a/components/src/form-field/index.ts b/components/src/form-field/index.ts new file mode 100644 index 0000000000..f6077fcc8e --- /dev/null +++ b/components/src/form-field/index.ts @@ -0,0 +1,2 @@ +export * from "./form-field.module"; +export * from "./form-field.component"; diff --git a/components/src/form-field/label.directive.ts b/components/src/form-field/label.directive.ts new file mode 100644 index 0000000000..a3a38329d6 --- /dev/null +++ b/components/src/form-field/label.directive.ts @@ -0,0 +1,6 @@ +import { Directive } from "@angular/core"; + +@Directive({ + selector: "bit-label", +}) +export class BitLabel {} diff --git a/components/src/form-field/prefix.directive.ts b/components/src/form-field/prefix.directive.ts new file mode 100644 index 0000000000..28ae6bd970 --- /dev/null +++ b/components/src/form-field/prefix.directive.ts @@ -0,0 +1,28 @@ +import { Directive, HostBinding, Input } from "@angular/core"; + +export const PrefixClasses = [ + "tw-block", + "tw-px-3", + "tw-py-1.5", + "tw-bg-background-alt", + "tw-border", + "tw-border-solid", + "tw-border-secondary-500", + "tw-text-muted", + "tw-rounded", +]; + +@Directive({ + selector: "[bitPrefix]", +}) +export class BitPrefixDirective { + @HostBinding("class") @Input() get classList() { + return PrefixClasses.concat([ + "tw-border-r-0", + "tw-rounded-r-none", + !this.first ? "tw-rounded-l-none" : "", + ]).filter((c) => c != ""); + } + + @Input() first = false; +} diff --git a/components/src/form-field/suffix.directive.ts b/components/src/form-field/suffix.directive.ts new file mode 100644 index 0000000000..c644e80cb2 --- /dev/null +++ b/components/src/form-field/suffix.directive.ts @@ -0,0 +1,18 @@ +import { Directive, HostBinding, Input } from "@angular/core"; + +import { PrefixClasses } from "./prefix.directive"; + +@Directive({ + selector: "[bitSuffix]", +}) +export class BitSuffixDirective { + @HostBinding("class") @Input() get classList() { + return PrefixClasses.concat([ + "tw-rounded-l-none", + "tw-border-l-0", + !this.last ? "tw-rounded-r-none" : "", + ]).filter((c) => c != ""); + } + + @Input() last = false; +} diff --git a/components/src/index.ts b/components/src/index.ts index ac56e57a09..9a15e43815 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -2,4 +2,5 @@ export * from "./badge"; export * from "./banner"; export * from "./button"; export * from "./callout"; +export * from "./form-field"; export * from "./menu"; diff --git a/components/src/input/input.directive.ts b/components/src/input/input.directive.ts new file mode 100644 index 0000000000..f75e3da61c --- /dev/null +++ b/components/src/input/input.directive.ts @@ -0,0 +1,65 @@ +import { Directive, HostBinding, Input, Optional, Self } from "@angular/core"; +import { NgControl, Validators } from "@angular/forms"; + +// Increments for each instance of this component +let nextId = 0; + +@Directive({ + selector: "input[bitInput], select[bitInput], textarea[bitInput]", +}) +export class BitInputDirective { + @HostBinding("class") @Input() get classList() { + return [ + "tw-block", + "tw-w-full", + "tw-px-3", + "tw-py-1.5", + "tw-bg-background-alt", + "tw-border", + "tw-border-solid", + "tw-rounded", + "tw-text-main", + "tw-placeholder-text-muted", + "focus:tw-outline-none", + "focus:tw-border-primary-700", + "focus:tw-ring-1", + "focus:tw-ring-primary-700", + "focus:tw-z-10", + "disabled:tw-bg-secondary-100", + this.hasPrefix ? "tw-rounded-l-none" : "", + this.hasSuffix ? "tw-rounded-r-none" : "", + this.hasError ? "tw-border-danger-500" : "tw-border-secondary-500", + ].filter((s) => s != ""); + } + + @HostBinding() id = `bit-input-${nextId++}`; + + @HostBinding("attr.aria-describedby") ariaDescribedBy: string; + + @HostBinding("attr.aria-invalid") get ariaInvalid() { + return this.hasError ? true : undefined; + } + + @HostBinding() + @Input() + get required() { + return this._required ?? this.ngControl?.control?.hasValidator(Validators.required) ?? false; + } + set required(value: any) { + this._required = value != null && value !== false; + } + private _required: boolean; + + @Input() hasPrefix = false; + @Input() hasSuffix = false; + + 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]]; + } + constructor(@Optional() @Self() private ngControl: NgControl) {} +} diff --git a/components/src/input/input.module.ts b/components/src/input/input.module.ts new file mode 100644 index 0000000000..cfc49cefb7 --- /dev/null +++ b/components/src/input/input.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { BitInputDirective } from "./input.directive"; + +@NgModule({ + imports: [CommonModule], + declarations: [BitInputDirective], + exports: [BitInputDirective], +}) +export class InputModule {} diff --git a/components/tsconfig.json b/components/tsconfig.json index 9a15531bbf..549eeed211 100644 --- a/components/tsconfig.json +++ b/components/tsconfig.json @@ -19,7 +19,8 @@ "module": "es2020", "lib": ["es2020", "dom"], "paths": { - "jslib-common/*": ["../common/src/*"] + "jslib-common/*": ["../common/src/*"], + "jslib-angular/*": ["../angular/src/*"] } }, "angularCompilerOptions": {