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: [