diff --git a/components/src/button/button.component.ts b/components/src/button/button.component.ts
index 172be48b..49f4e678 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 00000000..fd359cc1
--- /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: ` 0">
+ {{ "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 00000000..f60038da
--- /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: `
+
+ `,
+});
+
+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 00000000..72846307
--- /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 00000000..00c844c3
--- /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 00000000..29c4ba36
--- /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 00000000..323d5a52
--- /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 00000000..54881314
--- /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: `
+
+ `,
+});
+
+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 00000000..59d01a89
--- /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 00000000..f6077fcc
--- /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 00000000..a3a38329
--- /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 00000000..28ae6bd9
--- /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 00000000..c644e80c
--- /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 ac56e57a..9a15e438 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 00000000..f75e3da6
--- /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 00000000..cfc49cef
--- /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 9a15531b..549eeed2 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": {