[CL-23] Forms (#786)

This commit is contained in:
Oscar Hinton 2022-06-02 09:34:13 +02:00 committed by GitHub
parent 911cf794f4
commit 78d2f957d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 639 additions and 1 deletions

View File

@ -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"],
];

View File

@ -0,0 +1,39 @@
import { Component, Input } from "@angular/core";
import { AbstractControl, FormGroup } from "@angular/forms";
@Component({
selector: "bit-error-summary",
template: ` <ng-container *ngIf="errorCount > 0">
<i class="bwi bwi-error"></i> {{ "fieldsNeedAttention" | i18n: errorString }}
</ng-container>`,
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);
}
}

View File

@ -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<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: {
formObj: formObj,
submit: submit,
...args,
},
template: `
<form [formGroup]="formObj" (ngSubmit)="submit()">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" />
</bit-form-field>
<button type="submit" bit-button buttonType="primary">Submit</button>
<bit-error-summary [formGroup]="formObj"></bit-error-summary>
</form>
`,
});
export const Default = Template.bind({});
Default.props = {};

View File

@ -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: `<i class="bwi bwi-error"></i> {{ 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;
}
}
}

View File

@ -0,0 +1,17 @@
<label class="tw-block tw-font-semibold tw-mb-1 tw-text-main" [attr.for]="input.id">
<ng-content select="bit-label"></ng-content>
<span *ngIf="input.required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
</label>
<div class="tw-flex">
<div *ngIf="prefixChildren.length" class="tw-flex">
<ng-content select="[bitPrefix]"></ng-content>
</div>
<ng-content></ng-content>
<div *ngIf="prefixChildren.length" class="tw-flex">
<ng-content select="[bitSuffix]"></ng-content>
</div>
</div>
<ng-container [ngSwitch]="input.hasError">
<ng-content select="bit-hint" *ngSwitchCase="false"></ng-content>
<bit-error [error]="input.error" *ngSwitchCase="true"></bit-error>
</ng-container>

View File

@ -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<BitPrefixDirective>;
@ContentChildren(BitSuffixDirective) suffixChildren: QueryList<BitSuffixDirective>;
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;
}
}
}

View File

@ -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 {}

View File

@ -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<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: {
formObj: defaultFormObj,
submit: submit,
...args,
},
template: `
<form [formGroup]="formObj" (ngSubmit)="submit()">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" />
</bit-form-field>
<button type="submit" bit-button buttonType="primary">Submit</button>
</form>
`,
});
export const Default = Template.bind({});
Default.props = {};
const RequiredTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: {
formObj: formObj,
...args,
},
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput required placeholder="Placeholder" />
</bit-form-field>
<bit-form-field [formGroup]="formObj">
<bit-label>FormControl</bit-label>
<input bitInput formControlName="required" placeholder="Placeholder" />
</bit-form-field>
`,
});
export const Required = RequiredTemplate.bind({});
Required.props = {};
const HintTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: {
formObj: formObj,
...args,
},
template: `
<bit-form-field [formGroup]="formObj">
<bit-label>FormControl</bit-label>
<input bitInput formControlName="required" placeholder="Placeholder" />
<bit-hint>Long hint text</bit-hint>
</bit-form-field>
`,
});
export const Hint = HintTemplate.bind({});
Required.props = {};
const DisabledTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: args,
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" disabled />
</bit-form-field>
`,
});
export const Disabled = DisabledTemplate.bind({});
Disabled.args = {};
const GroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: args,
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" />
<span bitPrefix>$</span>
<span bitSuffix>USD</span>
</bit-form-field>
`,
});
export const InputGroup = GroupTemplate.bind({});
InputGroup.args = {};
const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: args,
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" />
<button bitPrefix bit-button>Button</button>
<button bitPrefix bit-button>Button</button>
<button bitSuffix bit-button>
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
</button>
<button bitSuffix bit-button>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
</bit-form-field>
`,
});
export const ButtonInputGroup = ButtonGroupTemplate.bind({});
ButtonInputGroup.args = {};
const SelectTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: args,
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<select bitInput>
<option>Select</option>
<option>Other</option>
</select>
</bit-form-field>
`,
});
export const Select = SelectTemplate.bind({});
Select.args = {};
const TextareaTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: args,
template: `
<bit-form-field>
<bit-label>Textarea</bit-label>
<textarea bitInput rows="4"></textarea>
</bit-form-field>
`,
});
export const Textarea = TextareaTemplate.bind({});
Textarea.args = {};

View File

@ -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++}`;
}

View File

@ -0,0 +1,2 @@
export * from "./form-field.module";
export * from "./form-field.component";

View File

@ -0,0 +1,6 @@
import { Directive } from "@angular/core";
@Directive({
selector: "bit-label",
})
export class BitLabel {}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -2,4 +2,5 @@ export * from "./badge";
export * from "./banner";
export * from "./button";
export * from "./callout";
export * from "./form-field";
export * from "./menu";

View File

@ -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) {}
}

View File

@ -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 {}

View File

@ -19,7 +19,8 @@
"module": "es2020",
"lib": ["es2020", "dom"],
"paths": {
"jslib-common/*": ["../common/src/*"]
"jslib-common/*": ["../common/src/*"],
"jslib-angular/*": ["../angular/src/*"]
}
},
"angularCompilerOptions": {