diff --git a/src/form/README.md b/src/form/README.md index 294d992..1b5a590 100644 --- a/src/form/README.md +++ b/src/form/README.md @@ -33,7 +33,7 @@ form.resetValidation(); ```html
- + <-- If you don't want to revalidate as the value changes diff --git a/src/form/definitions.d.ts b/src/form/definitions.d.ts index 26032bd..13ea019 100644 --- a/src/form/definitions.d.ts +++ b/src/form/definitions.d.ts @@ -7,8 +7,9 @@ import { TPFormFieldElement } from './tp-form-field'; * Form Validator. */ export interface TPFormValidator { - validate: { ( field: TPFormFieldElement ): boolean }; + validate: { ( field: TPFormFieldElement ): boolean | Promise }; getErrorMessage: { ( field: TPFormFieldElement ): string }; + getSuspenseMessage?: { ( field: TPFormFieldElement ): string }; } /** @@ -23,5 +24,8 @@ declare global { tpFormErrors: { [ key: string ]: string; }; + tpFormSuspenseMessages: { + [ key: string ]: string; + }; } } diff --git a/src/form/index.html b/src/form/index.html index 05cdb02..c70c363 100644 --- a/src/form/index.html +++ b/src/form/index.html @@ -29,11 +29,56 @@ width: 100%; } + +
+

Synchronous Form

+ + + + + + + + + + + + + + + + + + + + +
+ + +
+

Asynchronous Form

@@ -55,6 +100,10 @@ + + + + diff --git a/src/form/index.ts b/src/form/index.ts index 4f9c6de..eb0c142 100644 --- a/src/form/index.ts +++ b/src/form/index.ts @@ -29,6 +29,7 @@ const validators = [ */ window.tpFormValidators = {}; window.tpFormErrors = {}; +window.tpFormSuspenseMessages = {}; // Register validators. validators.forEach( ( @@ -45,6 +46,7 @@ validators.forEach( ( import { TPFormElement } from './tp-form'; import { TPFormFieldElement } from './tp-form-field'; import { TPFormErrorElement } from './tp-form-error'; +import { TPFormSuspenseElement } from './tp-form-suspense'; import { TPFormSubmitElement } from './tp-form-submit'; /** @@ -53,4 +55,5 @@ import { TPFormSubmitElement } from './tp-form-submit'; customElements.define( 'tp-form', TPFormElement ); customElements.define( 'tp-form-field', TPFormFieldElement ); customElements.define( 'tp-form-error', TPFormErrorElement ); +customElements.define( 'tp-form-suspense', TPFormSuspenseElement ); customElements.define( 'tp-form-submit', TPFormSubmitElement ); diff --git a/src/form/tp-form-field.ts b/src/form/tp-form-field.ts index 0b7886c..8b8fd9c 100644 --- a/src/form/tp-form-field.ts +++ b/src/form/tp-form-field.ts @@ -1,7 +1,9 @@ /** * Internal dependencies. */ +import { TPFormElement } from './tp-form'; import { TPFormErrorElement } from './tp-form-error'; +import { TPFormSuspenseElement } from './tp-form-suspense'; /** * TP Form Field. @@ -26,9 +28,16 @@ export class TPFormFieldElement extends HTMLElement { * Update validation when the field has changed. */ handleFieldChanged(): void { + // Check if we want to ignore field revalidations. + if ( 'no' === this.getAttribute( 'revalidate-on-change' ) ) { + // Yes we do, bail! + return; + } + // Validate the field again if 'valid' or 'error' attribute is present. if ( this.getAttribute( 'valid' ) || this.getAttribute( 'error' ) ) { - this.validate(); + const form: TPFormElement | null = this.closest( 'tp-form' ); + form?.validateField( this ); } } @@ -39,7 +48,7 @@ export class TPFormFieldElement extends HTMLElement { */ static get observedAttributes(): string[] { // Attributes observed in the TPFormFieldElement web-component. - return [ 'valid', 'error' ]; + return [ 'valid', 'error', 'suspense' ]; } /** @@ -53,7 +62,7 @@ export class TPFormFieldElement extends HTMLElement { // Check if the observed attributes 'valid' or 'error' have changed. // Dispatch a custom 'validate' event. - if ( ( 'valid' === name || 'error' === name ) && oldValue !== newValue ) { + if ( ( 'valid' === name || 'error' === name || 'suspense' === name ) && oldValue !== newValue ) { this.dispatchEvent( new CustomEvent( 'validate', { bubbles: true } ) ); } @@ -70,7 +79,7 @@ export class TPFormFieldElement extends HTMLElement { // Exit the function if validators are not available. if ( ! tpFormValidators ) { - //Early return + // Early return. return; } @@ -83,6 +92,17 @@ export class TPFormFieldElement extends HTMLElement { } else { this.removeErrorMessage(); } + + // Get the 'suspense' attribute value. + const suspense: string = this.getAttribute( 'suspense' ) ?? ''; + + // Check if the suspense exists and has a corresponding suspense message function. + if ( '' !== suspense && suspense in tpFormValidators && 'function' === typeof tpFormValidators[ suspense ].getSuspenseMessage ) { + // @ts-ignore + this.setSuspenseMessage( tpFormValidators[ suspense ]?.getSuspenseMessage( this ) ); + } else { + this.removeSuspenseMessage(); + } } /** @@ -100,7 +120,7 @@ export class TPFormFieldElement extends HTMLElement { * * @return {boolean} Whether this field passed validation. */ - validate(): boolean { + async validate(): Promise { // Retrieve tpFormValidators from the window object. const { tpFormValidators } = window; @@ -118,6 +138,7 @@ export class TPFormFieldElement extends HTMLElement { // Prepare error and valid status. let valid: boolean = true; + let suspense: Promise | null = null; let error: string = ''; const allAttributes: string[] = this.getAttributeNames(); @@ -126,14 +147,64 @@ export class TPFormFieldElement extends HTMLElement { // Check if the attribute is a validator. if ( attributeName in tpFormValidators && 'function' === typeof tpFormValidators[ attributeName ].validate ) { // We found one, lets validate the field. - const isValid: boolean = tpFormValidators[ attributeName ].validate( this ); + const isValid: boolean | Promise = tpFormValidators[ attributeName ].validate( this ); + error = attributeName; + + // First check for a Promise. + if ( isValid instanceof Promise ) { + // Yes it is an async validation. + valid = false; - // Looks like we found an error! - if ( false === isValid ) { + // Dispatch a custom 'validation-suspense-start' event. + this.dispatchEvent( new CustomEvent( 'validation-suspense-start' ) ); + + // Create the promise. + suspense = new Promise( ( resolve, reject ): void => { + // Validate it. + isValid + .then( ( suspenseIsValid: boolean ) => { + // Validation is complete. + if ( true === suspenseIsValid ) { + this.setAttribute( 'valid', 'yes' ); + this.removeAttribute( 'error' ); + + // Resolve the promise. + resolve( true ); + } else { + this.removeAttribute( 'valid' ); + this.setAttribute( 'error', error ); + + // Resolve the promise. + resolve( false ); + } + + // Dispatch a custom 'validation-suspense-success' event. + this.dispatchEvent( new CustomEvent( 'validation-suspense-success' ) ); + } ) + .catch( (): void => { + // There was an error. + this.removeAttribute( 'valid' ); + this.setAttribute( 'error', error ); + + // Dispatch a custom 'validation-suspense-error' event. + this.dispatchEvent( new CustomEvent( 'validation-suspense-error' ) ); + + // Reject the promise. + reject( false ); + } ) + .finally( (): void => { + // Clean up. + this.removeAttribute( 'suspense' ); + } ); + } ); + + // Return. + return false; + } else if ( false === isValid ) { + // Not a Promise, but looks like we found an error! valid = false; - error = attributeName; - // return false; + // Return. return false; } } @@ -146,12 +217,27 @@ export class TPFormFieldElement extends HTMLElement { if ( valid ) { this.setAttribute( 'valid', 'yes' ); this.removeAttribute( 'error' ); + this.removeAttribute( 'suspense' ); } else { this.removeAttribute( 'valid' ); - this.setAttribute( 'error', error ); + + // Check for suspense. + if ( suspense ) { + this.setAttribute( 'suspense', error ); + this.removeAttribute( 'error' ); + } else { + this.removeAttribute( 'suspense' ); + this.setAttribute( 'error', error ); + } } - // Return validity. + // Do we have a suspense? + if ( suspense ) { + // Yes we do, return the promise. + return suspense; + } + + // No we don't, return a resolved promise. return valid; } @@ -187,4 +273,31 @@ export class TPFormFieldElement extends HTMLElement { // Dispatch a custom 'validation-success' event. this.dispatchEvent( new CustomEvent( 'validation-success' ) ); } + + /** + * Set the suspense message. + * + * @param {string} message Suspense message. + */ + setSuspenseMessage( message: string = '' ): void { + // Look for an existing tp-form-error element. + const suspense: TPFormSuspenseElement | null = this.querySelector( 'tp-form-suspense' ); + + // If found, update its innerHTML with the suspense message. Otherwise, create a new tp-form-suspense element and append it to the component. + if ( suspense ) { + suspense.innerHTML = message; + } else { + const suspenseElement: TPFormSuspenseElement = document.createElement( 'tp-form-suspense' ); + suspenseElement.innerHTML = message; + this.appendChild( suspenseElement ); + } + } + + /** + * Remove the suspense message. + */ + removeSuspenseMessage(): void { + // Find and remove the tp-form-suspense element. + this.querySelector( 'tp-form-suspense' )?.remove(); + } } diff --git a/src/form/tp-form-suspense.ts b/src/form/tp-form-suspense.ts new file mode 100644 index 0000000..90dc398 --- /dev/null +++ b/src/form/tp-form-suspense.ts @@ -0,0 +1,5 @@ +/** + * TP Form Suspense. + */ +export class TPFormSuspenseElement extends HTMLElement { +} diff --git a/src/form/tp-form.ts b/src/form/tp-form.ts index b0d2fa9..858fbec 100644 --- a/src/form/tp-form.ts +++ b/src/form/tp-form.ts @@ -32,32 +32,36 @@ export class TPFormElement extends HTMLElement { * * @param {Event} e Submit event. */ - protected handleFormSubmit( e: SubmitEvent ): void { - // Validate the form. - const formValid: boolean = this.validate(); - - // Prevent form submission if it's invalid. - if ( ! formValid || 'yes' === this.getAttribute( 'prevent-submit' ) ) { - e.preventDefault(); - e.stopImmediatePropagation(); - } + protected async handleFormSubmit( e: SubmitEvent ): Promise { + // Stop normal form submission. + e.preventDefault(); + e.stopImmediatePropagation(); // Get submit button. const submit: TPFormSubmitElement | null = this.querySelector( 'tp-form-submit' ); + submit?.setAttribute( 'submitting', 'yes' ); - // If present. - if ( submit ) { - // Check if form is valid. - if ( formValid ) { - submit.setAttribute( 'submitting', 'yes' ); - } else { - submit.removeAttribute( 'submitting' ); - } + // Ignore a form that currently has suspense. + if ( 'yes' === this.getAttribute( 'suspense' ) ) { + // Bail early. + return; } + // Validate the form. + const formValid: boolean = await this.validate(); + // If form is valid then dispatch a custom 'submit-validation-success' event. if ( formValid ) { + // Trigger event. this.dispatchEvent( new CustomEvent( 'submit-validation-success', { bubbles: true } ) ); + + // Submit form. + if ( 'yes' !== this.getAttribute( 'prevent-submit' ) ) { + this.form?.submit(); + } + } else { + // Form is not valid, remove submitting attribute. + submit?.removeAttribute( 'submitting' ); } } @@ -66,7 +70,7 @@ export class TPFormElement extends HTMLElement { * * @return {boolean} Whether the form is valid or not. */ - validate(): boolean { + async validate(): Promise { // Dispatch a custom 'validate' event. this.dispatchEvent( new CustomEvent( 'validate', { bubbles: true } ) ); @@ -81,14 +85,26 @@ export class TPFormElement extends HTMLElement { return true; } + // Start by setting the form as suspense. + this.setAttribute( 'suspense', 'yes' ); + // Check if all fields are valid. let formValid: boolean = true; - fields.forEach( ( field: TPFormFieldElement ): void => { - // Validate the field. - if ( ! field.validate() ) { + const validationPromises: Promise[] = Array + .from( fields ) + .map( async ( field: TPFormFieldElement ): Promise => await field.validate() ); + + // Wait for promises to resolve. + await Promise.all( validationPromises ) + .then( ( results: boolean[] ): void => { + // Check if all fields are valid. + formValid = results.every( ( isValid: boolean ) => isValid ); + } ) + .catch( () => { + // There was an error with one or more fields. formValid = false; - } - } ); + } ) + .finally( () => this.removeAttribute( 'suspense' ) ); // If form is valid then dispatch a custom 'validation-success' event else send a custom 'validation-error' event. if ( formValid ) { @@ -101,6 +117,21 @@ export class TPFormElement extends HTMLElement { return formValid; } + /** + * Validate one field. + * + * @param {HTMLElement} field Field node. + */ + async validateField( field: TPFormFieldElement ): Promise { + // Set form as suspense, validate and undo suspense. + this.setAttribute( 'suspense', 'yes' ); + const fieldValid: boolean = await field.validate(); + this.removeAttribute( 'suspense' ); + + // Return result. + return fieldValid; + } + /** * Reset form validation. */ @@ -116,15 +147,17 @@ export class TPFormElement extends HTMLElement { // Remove 'valid' and 'error' attributes from all fields. fields.forEach( ( field: TPFormFieldElement ): void => { - // Remove 'valid' and 'error' attribute. + // Remove 'valid' and 'error' and 'suspense' attributes. field.removeAttribute( 'valid' ); field.removeAttribute( 'error' ); + field.removeAttribute( 'suspense' ); } ); - // Get submit button. - const submit: TPFormSubmitElement | null = this.querySelector( 'tp-form-submit' ); + // Remove 'suspense' attribute from form. + this.removeAttribute( 'suspense' ); // Remove 'submitting' attribute from submit button. + const submit: TPFormSubmitElement | null = this.querySelector( 'tp-form-submit' ); submit?.removeAttribute( 'submitting' ); } }