diff --git a/src/form/definitions.d.ts b/src/form/definitions.d.ts new file mode 100644 index 0000000..34bee29 --- /dev/null +++ b/src/form/definitions.d.ts @@ -0,0 +1,26 @@ +/** + * Internal dependencies. + */ +import { TPFormFieldElement } from './tp-form-field'; + +/** + * Form Validator. + */ +export interface TPFormValidator { + validate: { ( field: TPFormFieldElement ): boolean }; + getErrorMessage: { ( field: TPFormFieldElement ): string }; +} + +/** + * Window. + */ +declare global { + interface Window { + tpFormValidators: { + [ key: string ]: TPFormValidator; + } + tpFormErrors: { + [ key: string ]: string; + }; + } +} diff --git a/src/form/index.html b/src/form/index.html new file mode 100644 index 0000000..f459034 --- /dev/null +++ b/src/form/index.html @@ -0,0 +1,65 @@ + + + + + + + Web Component: Form + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + diff --git a/src/form/index.ts b/src/form/index.ts new file mode 100644 index 0000000..0a83b11 --- /dev/null +++ b/src/form/index.ts @@ -0,0 +1,50 @@ +/** + * Styles. + */ +import './style.scss'; + +/** + * Validators. + */ +import { TPFormValidator } from './definitions'; +import * as required from './validators/required'; +import * as email from './validators/email'; +import * as minLength from './validators/min-length'; +import * as maxLength from './validators/max-length'; + +const validators = [ + required, + email, + minLength, + maxLength, +]; + +/** + * Register Validators and Errors. + */ +window.tpFormValidators = {}; +window.tpFormErrors = {}; + +validators.forEach( ( + { name, validator, errorMessage }: { name: string, validator: TPFormValidator, errorMessage: string } +): void => { + window.tpFormValidators[ name ] = validator; + window.tpFormErrors[ name ] = errorMessage; +} ); + +/** + * Components. + */ +import { TPFormElement } from './tp-form'; +import { TPFormFieldElement } from './tp-form-field'; +import { TPFormErrorElement } from './tp-form-error'; +import { TPFormSubmitElement } from './tp-form-submit'; + +/** + * Register Components. + */ +customElements.define( 'tp-form', TPFormElement ); +customElements.define( 'tp-form-field', TPFormFieldElement ); +customElements.define( 'tp-form-error', TPFormErrorElement ); +customElements.define( 'tp-form-submit', TPFormSubmitElement ); + diff --git a/src/form/style.scss b/src/form/style.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/form/tp-form-error.ts b/src/form/tp-form-error.ts new file mode 100644 index 0000000..917f650 --- /dev/null +++ b/src/form/tp-form-error.ts @@ -0,0 +1,5 @@ +/** + * TP Form Error. + */ +export class TPFormErrorElement extends HTMLElement { +} diff --git a/src/form/tp-form-field.ts b/src/form/tp-form-field.ts new file mode 100644 index 0000000..67961bb --- /dev/null +++ b/src/form/tp-form-field.ts @@ -0,0 +1,150 @@ +/** + * Internal dependencies. + */ +import { TPFormErrorElement } from './tp-form-error'; + +/** + * TP Form Field. + */ +export class TPFormFieldElement extends HTMLElement { + /** + * Connected callback. + */ + connectedCallback(): void { + const field = this.getField(); + field?.addEventListener( 'keyup', this.handleFieldChanged.bind( this ) ); + field?.addEventListener( 'change', this.handleFieldChanged.bind( this ) ); + } + + /** + * Update validation when the field has changed. + */ + handleFieldChanged(): void { + if ( this.getAttribute( 'valid' ) || this.getAttribute( 'error' ) ) { + this.validate(); + } + } + + /** + * Get observed attributes. + * + * @return {Array} List of observed attributes. + */ + static get observedAttributes(): string[] { + return [ 'valid', 'error' ]; + } + + /** + * Attribute changed callback. + * + * @param {string} name Attribute name. + * @param {string} oldValue Old value. + * @param {string} newValue New value. + */ + attributeChangedCallback( name: string = '', oldValue: string = '', newValue: string = '' ): void { + if ( ( 'valid' === name || 'error' === name ) && oldValue !== newValue ) { + this.dispatchEvent( new CustomEvent( 'validate', { bubbles: true } ) ); + } + this.update(); + } + + /** + * Update component. + */ + update(): void { + const { tpFormValidators } = window; + if ( ! tpFormValidators ) { + return; + } + + const error: string = this.getAttribute( 'error' ) ?? ''; + if ( '' !== error && error in tpFormValidators && 'function' === typeof tpFormValidators[ error ].getErrorMessage ) { + this.setErrorMessage( tpFormValidators[ error ].getErrorMessage( this ) ); + } else { + this.removeErrorMessage(); + } + } + + /** + * Get the associated field. + * + * @return {HTMLElement} The associated field for this component. + */ + getField(): HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null { + return this.querySelector( 'input,select,textarea' ); + } + + /** + * Validate this field. + * + * @return {boolean} Whether this field passed validation. + */ + validate(): boolean { + // Look for validators. + const { tpFormValidators } = window; + if ( ! tpFormValidators ) { + return true; + } + + // Prepare error and valid status. + let valid: boolean = true; + let error: string = ''; + const allAttributes: string[] = this.getAttributeNames(); + + // Traverse all attributes to see if we find a matching validator. + allAttributes.every( ( attributeName: string ): boolean => { + if ( attributeName in tpFormValidators && 'function' === typeof tpFormValidators[ attributeName ].validate ) { + // We found one, lets validate the field. + const isValid: boolean = tpFormValidators[ attributeName ].validate( this ); + + // Looks like we found an error! + if ( false === isValid ) { + valid = false; + error = attributeName; + return false; + } + } + + // No error found, all good. + return true; + } ); + + // Check if the field is valid or not. + if ( valid ) { + this.setAttribute( 'valid', 'yes' ); + this.removeAttribute( 'error' ); + } else { + this.removeAttribute( 'valid' ); + this.setAttribute( 'error', error ); + } + + // Return validity. + return valid; + } + + /** + * Set the error message. + * + * @param {string} message Error message. + */ + setErrorMessage( message: string = '' ): void { + const error: TPFormErrorElement | null = this.querySelector( 'tp-form-error' ); + if ( error ) { + error.innerHTML = message; + } else { + const errorElement: TPFormErrorElement = document.createElement( 'tp-form-error' ); + errorElement.innerHTML = message; + this.appendChild( errorElement ); + } + + this.dispatchEvent( new CustomEvent( 'validation-error', { bubbles: true } ) ); + } + + /** + * Remove the error message. + */ + removeErrorMessage(): void { + this.querySelector( 'tp-form-error' )?.remove(); + this.dispatchEvent( new CustomEvent( 'validation-success', { bubbles: true } ) ); + } +} diff --git a/src/form/tp-form-submit.ts b/src/form/tp-form-submit.ts new file mode 100644 index 0000000..5b04d2a --- /dev/null +++ b/src/form/tp-form-submit.ts @@ -0,0 +1,53 @@ +/** + * TP Form Submit. + */ +export class TPFormSubmitElement extends HTMLElement { + /** + * Get observed attributes. + * + * @return {Array} List of observed attributes. + */ + static get observedAttributes(): string[] { + return [ 'submitting-text', 'original-text', 'submitting' ]; + } + + /** + * Attribute changed callback. + * + * @param {string} _name Attribute name. + * @param {string} oldValue Old value. + * @param {string} newValue New value. + */ + attributeChangedCallback( _name: string = '', oldValue: string = '', newValue: string = '' ): void { + if ( oldValue !== newValue ) { + this.update(); + } + } + + /** + * Update this component. + */ + update(): void { + // Get submit button. + const submitButton: HTMLButtonElement | null = this.querySelector( 'button[type="submit"]' ); + if ( ! submitButton ) { + return; + } + + // Prepare submit button text. + const submittingText: string = this.getAttribute( 'submitting-text' ) ?? ''; + const originalText: string = this.getAttribute( 'original-text' ) ?? submitButton.innerHTML; + + // Check if we are submitting. + if ( 'yes' === this.getAttribute( 'submitting' ) ) { + submitButton.setAttribute( 'disabled', 'disabled' ); + this.setAttribute( 'original-text', originalText ); + submitButton.innerHTML = submittingText; + } else { + submitButton.removeAttribute( 'disabled' ); + this.removeAttribute( 'submitting' ); + this.removeAttribute( 'original-text' ); + submitButton.innerHTML = originalText; + } + } +} diff --git a/src/form/tp-form.ts b/src/form/tp-form.ts new file mode 100644 index 0000000..9e488bf --- /dev/null +++ b/src/form/tp-form.ts @@ -0,0 +1,99 @@ +/** + * Internal dependencies. + */ +import { TPFormFieldElement } from './tp-form-field'; +import { TPFormSubmitElement } from './tp-form-submit'; + +/** + * TP Form. + */ +export class TPFormElement extends HTMLElement { + /** + * Properties. + */ + protected readonly form: HTMLFormElement | null; + + /** + * Constructor. + */ + constructor() { + super(); + this.form = this.querySelector( 'form' ); + } + + /** + * Connected callback. + */ + connectedCallback(): void { + this.form?.addEventListener( 'submit', this.handleFormSubmit.bind( this ) ); + } + + /** + * Handle form submission. + * + * @param {Event} e Submit event. + */ + protected handleFormSubmit( e: SubmitEvent ): void { + const formValid: boolean = this.validate(); + if ( ! formValid || 'yes' === this.getAttribute( 'prevent-submit' ) ) { + e.preventDefault(); + } + + const submit: TPFormSubmitElement | null = this.querySelector( 'tp-form-submit' ); + if ( submit ) { + if ( formValid ) { + submit.setAttribute( 'submitting', 'yes' ); + } else { + submit.removeAttribute( 'submitting' ); + } + } + } + + /** + * Validate the form. + * + * @return {boolean} Whether the form is valid or not. + */ + validate(): boolean { + this.dispatchEvent( new CustomEvent( 'validate', { bubbles: true } ) ); + + const fields: NodeListOf | null = this.querySelectorAll( 'tp-form-field' ); + if ( ! fields ) { + this.dispatchEvent( new CustomEvent( 'validation-success', { bubbles: true } ) ); + return true; + } + + let formValid: boolean = true; + fields.forEach( ( field: TPFormFieldElement ): void => { + if ( ! field.validate() ) { + formValid = false; + } + } ); + + if ( formValid ) { + this.dispatchEvent( new CustomEvent( 'validation-success', { bubbles: true } ) ); + } else { + this.dispatchEvent( new CustomEvent( 'validation-error', { bubbles: true } ) ); + } + + return formValid; + } + + /** + * Reset form validation. + */ + resetValidation(): void { + const fields: NodeListOf | null = this.querySelectorAll( 'tp-form-field' ); + if ( ! fields ) { + return; + } + + fields.forEach( ( field: TPFormFieldElement ): void => { + field.removeAttribute( 'valid' ); + field.removeAttribute( 'error' ); + } ); + + const submit: TPFormSubmitElement | null = this.querySelector( 'tp-form-submit' ); + submit?.removeAttribute( 'submitting' ); + } +} diff --git a/src/form/utility.ts b/src/form/utility.ts new file mode 100644 index 0000000..7e367a8 --- /dev/null +++ b/src/form/utility.ts @@ -0,0 +1,19 @@ +/** + * Get the error message based on its code. + * + * @param {string} error Error code. + * + * @return {string} The error message. + */ +export const getErrorMessage = ( error: string = '' ): string => { + const { tpFormErrors } = window; + if ( ! tpFormErrors ) { + return ''; + } + + if ( '' !== error && error in tpFormErrors && 'string' === typeof tpFormErrors[ error ] ) { + return tpFormErrors[ error ]; + } + + return ''; +}; diff --git a/src/form/validators/email.ts b/src/form/validators/email.ts new file mode 100644 index 0000000..4d216f5 --- /dev/null +++ b/src/form/validators/email.ts @@ -0,0 +1,26 @@ +/** + * Internal dependencies. + */ +import { TPFormFieldElement } from '../tp-form-field'; +import { TPFormValidator } from '../definitions'; +import { getErrorMessage } from '../utility'; + +/** + * Name. + */ +export const name: string = 'email'; + +/** + * Error message. + */ +export const errorMessage: string = 'Please enter a valid email address'; + +/** + * Validator. + */ +export const validator: TPFormValidator = { + validate: ( field: TPFormFieldElement ): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( field.getField()?.value ?? '' ); + }, + getErrorMessage: (): string => getErrorMessage( name ), +}; diff --git a/src/form/validators/max-length.ts b/src/form/validators/max-length.ts new file mode 100644 index 0000000..6098d25 --- /dev/null +++ b/src/form/validators/max-length.ts @@ -0,0 +1,34 @@ +/** + * Internal dependencies. + */ +import { TPFormFieldElement } from '../tp-form-field'; +import { TPFormValidator } from '../definitions'; +import { getErrorMessage } from '../utility'; + +/** + * Name. + */ +export const name: string = 'max-length'; + +/** + * Error message. + */ +export const errorMessage: string = 'Must be less than %1 characters'; + +/** + * Validator. + */ +export const validator: TPFormValidator = { + validate: ( field: TPFormFieldElement ): boolean => { + const minLength: number = parseInt( field.getAttribute( 'max-length' ) ?? '0' ); + const value: string = field.getField()?.value ?? ''; + + return '' === value || value.length <= minLength; + }, + getErrorMessage: ( field: TPFormFieldElement ): string => { + const error: string = getErrorMessage( name ); + const maxLength: string = field.getAttribute( 'max-length' ) ?? ''; + + return error.replace( '%1', maxLength ); + }, +}; diff --git a/src/form/validators/min-length.ts b/src/form/validators/min-length.ts new file mode 100644 index 0000000..a06b592 --- /dev/null +++ b/src/form/validators/min-length.ts @@ -0,0 +1,34 @@ +/** + * Internal dependencies. + */ +import { TPFormFieldElement } from '../tp-form-field'; +import { TPFormValidator } from '../definitions'; +import { getErrorMessage } from '../utility'; + +/** + * Name. + */ +export const name: string = 'min-length'; + +/** + * Error message. + */ +export const errorMessage: string = 'Must be at least %1 characters'; + +/** + * Validator. + */ +export const validator: TPFormValidator = { + validate: ( field: TPFormFieldElement ): boolean => { + const minLength: number = parseInt( field.getAttribute( 'min-length' ) ?? '0' ); + const value: string = field.getField()?.value ?? ''; + + return '' === value || value.length >= minLength; + }, + getErrorMessage: ( field: TPFormFieldElement ): string => { + const error: string = getErrorMessage( name ); + const minLength: string = field.getAttribute( 'min-length' ) ?? ''; + + return error.replace( '%1', minLength ); + }, +}; diff --git a/src/form/validators/required.ts b/src/form/validators/required.ts new file mode 100644 index 0000000..9ee93eb --- /dev/null +++ b/src/form/validators/required.ts @@ -0,0 +1,26 @@ +/** + * Internal dependencies. + */ +import { TPFormFieldElement } from '../tp-form-field'; +import { TPFormValidator } from '../definitions'; +import { getErrorMessage } from '../utility'; + +/** + * Name. + */ +export const name: string = 'required'; + +/** + * Error message. + */ +export const errorMessage: string = 'This field is required'; + +/** + * Validator. + */ +export const validator: TPFormValidator = { + validate: ( field: TPFormFieldElement ): boolean => { + return '' !== field.getField()?.value ?? ''; + }, + getErrorMessage: (): string => getErrorMessage( name ), +}; diff --git a/webpack.config.js b/webpack.config.js index 53e0545..e738954 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,7 +26,7 @@ class DeclarationBundlerPlugin { const declarationFiles = {}; for ( const filename in assets ) { if ( filename.endsWith( '.d.ts' ) ) { - if ( ! filename.includes( 'index.d.ts' ) ) { + if ( filename.includes( 'tp-' ) ) { declarationFiles[ filename ] = assets[ filename ]; } delete assets[ filename ]; @@ -78,6 +78,7 @@ module.exports = ( env ) => { entry: { modal: './src/modal/index.ts', slider: './src/slider/index.ts', + form: './src/form/index.ts', }, module: { rules: [