Source: dist/extras/cms-form/cms-form.js

/**
 * Extra - CMS Form
 *
 * Render a form using the form configuration
 *
 * @module Extras/CMS-Form
 * @license The MIT License (MIT)
 * @copyright (c) 2025 eVAL Agency
 * @author Charlie Powell
 * @see https://markdownmaster.com/docs/extras/cms-form.html
 * @since 5.0.0
 */

/**
 * Provides `<cms-form>` tag functionality.
 */
class CMSFormElement extends HTMLElement {

	constructor() {
		// Always call super first in constructor
		super();
		this.form = null;
		this.submitBtn = null;
		this.emailValidation = /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
	}

	/**
	 * Called when the element is added to the DOM.
	 */
	connectedCallback() {
		// Element is now connected to the DOM
		this.render();
	}

	/**
	 * Render this element into the DOM
	 */
	render() {

		let name = this.getAttribute('name'),
			successPage = this.getAttribute('success'),
			formData = CMS.config.extra('cms-form', name),
			submitRendered = false;

		if (!formData) {
			this.innerHTML = 'No form with name ' + name + ' configured.';
			console.error(`Form with name '${name}' not found.`);
			return;
		}

		this.form = document.createElement('form');
		this.form.setAttribute('method', 'POST');
		this.form.setAttribute('action', CMS.config.webpath + 'form');

		let input = document.createElement('input');
		input.setAttribute('type', 'hidden');
		input.setAttribute('name', 'form');
		input.setAttribute('value', name);
		this.form.appendChild(input);

		Object.keys(formData.fields).forEach(key => {
			let field = formData.fields[key];

			if (field.type === 'hidden') {
				this.form.appendChild(this._generateInput(key, field));
			}
			else if(field.type === 'checkboxes' || field.type === 'radio') {
				this.form.appendChild(this._generateLabel(key, field));
				this._generateInputs(key, field).forEach(el => {
					this.form.appendChild(el);
				})
				this.form.appendChild(this._generateError(key, field));
			}
			else if(field.type === 'submit') {
				submitRendered = true;
				this.submitBtn = this._generateInput(key, field);
				this.form.appendChild(this.submitBtn);
			}
			else {
				this.form.appendChild(this._generateLabel(key, field));
				this.form.appendChild(this._generateInput(key, field));
				this.form.appendChild(this._generateError(key, field));
			}
		});

		if (!submitRendered) {
			// Render a default submit button if none was provided
			this.submitBtn = document.createElement('input');
			this.submitBtn.setAttribute('type', 'submit');
			this.submitBtn.setAttribute('value', 'Submit');
			this.form.appendChild(this.submitBtn);
		}

		this.form.addEventListener('submit', this.formSubmitEvent.bind(this));

		this.appendChild(this.form);
	}

	/**
	 * Calculate the ID for a given field
	 *
	 * @param {string} key
	 * @returns {string}
	 * @private
	 */
	_getFieldId(key) {
		let id = [this.getAttribute('name'), key].join('-');
		// Santize the ID
		id = id.replace(/[^a-zA-Z0-9-_]/g, '_');
		return id;
	}

	/**
	 * Create a <label> element for a given field
	 *
	 * @param {string} key
	 * @param {Object} field
	 * @param {string|null}  field.label
	 * @param {string|null}  field.type
	 * @param {boolean|null} field.required
	 * @param {Object|null}  field.options
	 * @param {string|null}  field.value
	 * @param {string|null}  field.placeholder
	 * @returns {HTMLLabelElement}
	 * @private
	 */
	_generateLabel(key, field) {
		let label = document.createElement('label'),
			fieldId = this._getFieldId(key);

		label.innerHTML = field.label ?? key;
		label.setAttribute('for', fieldId);
		if (field.required) {
			label.innerHTML += ' <span class="required-note">*</span>';
		}

		return label;
	}

	/**
	 * Create an <input> element for a given field
	 *
	 * @param {string} key
	 * @param {Object} field
	 * @param {string|null}  field.label
	 * @param {string|null}  field.type
	 * @param {boolean|null} field.required
	 * @param {Object|null}  field.options
	 * @param {string|null}  field.value
	 * @param {string|null}  field.placeholder
	 * @returns {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement}
	 * @private
	 */
	_generateInput(key, field) {
		let fieldId = this._getFieldId(key),
			input;

		if (field.type === 'textarea') {
			input = document.createElement('textarea');
			if (field.value) {
				input.setAttribute('value', field.value);
			}
		}
		else if (field.type === 'select') {
			input = document.createElement('select');
			Object.keys(field.options).forEach(subkey => {
				let opt = document.createElement('option');
				opt.value = subkey;
				opt.innerHTML = field.options[subkey];
				if ((field.value || '') === subkey) {
					opt.setAttribute('selected', 'selected');
				}
				input.appendChild(opt);
			});
		}
		else {
			input = document.createElement('input');
			input.setAttribute('type', field.type ?? 'text');
			if (field.value) {
				input.setAttribute('value', field.value);
			}
		}

		// Set client-side validation checks
		if (field.type === 'email') {
			input.addEventListener('blur', this.emailValidateEvent.bind(this));
		}
		else {
			input.addEventListener('blur', this.genericValidateEvent.bind(this));
		}

		input.setAttribute('id', fieldId);
		input.setAttribute('name', key);
		if (field.placeholder) {
			input.setAttribute('placeholder', field.placeholder);
		}
		if (field.required) {
			input.setAttribute('required', 'required');
		}

		return input;
	}

	/**
	 * Create multiple <input> and <label> elements for a given field, usually for radio or checkboxes
	 *
	 * @param {string} key
	 * @param {Object} field
	 * @param {string|null}  field.label
	 * @param {string|null}  field.type
	 * @param {boolean|null} field.required
	 * @param {Object|null}  field.options
	 * @param {string|null}  field.value
	 * @param {string|null}  field.placeholder
	 * @returns {list<HTMLInputElement|HTMLLabelElement>}
	 * @private
	 */
	_generateInputs(key, field) {
		let fieldId = this._getFieldId(key),
			optCounter = 0,
			inputs = [];

		Object.keys(field.options).forEach(subkey => {
			let div = document.createElement('div'),
				input = document.createElement('input'),
				label = document.createElement('label');

			optCounter += 1;

			div.className = 'cms-form-option';

			if (field.type === 'checkboxes') {
				input.type = 'checkbox';
				input.setAttribute('name', key + '[]');
				div.classList.add('checkbox');
			}
			else {
				input.type = 'radio';
				input.setAttribute('name', key);
				div.classList.add('radio');
			}
			input.value = subkey;
			input.setAttribute('id', fieldId + '-' + optCounter);

			label.innerHTML = field.options[subkey];
			label.setAttribute('for', fieldId + '-' + optCounter);

			div.appendChild(input);
			div.appendChild(label);

			inputs.push(div);
		});

		return inputs;

		if (field.type === 'textarea') {
			input = document.createElement('textarea');
			if (field.value) {
				input.setAttribute('value', field.value);
			}
		}
		else if (field.type === 'select') {
			input = document.createElement('select');

		}
		else {
			input = document.createElement('input');
			input.setAttribute('type', field.type ?? 'text');
			if (field.value) {
				input.setAttribute('value', field.value);
			}
		}

		// Set client-side validation checks
		if (field.type === 'email') {
			input.addEventListener('blur', this.emailValidateEvent.bind(this));
		}
		else {
			input.addEventListener('blur', this.genericValidateEvent.bind(this));
		}


		if (field.placeholder) {
			input.setAttribute('placeholder', field.placeholder);
		}
		if (field.required) {
			input.setAttribute('required', 'required');
		}

		return input;
	}

	/**
	 * Create an error container <div> element for a given field
	 *
	 * @param {string} key
	 * @param {Object} field
	 * @param {string|null}  field.label
	 * @param {string|null}  field.type
	 * @param {boolean|null} field.required
	 * @param {Object|null}  field.options
	 * @param {string|null}  field.value
	 * @param {string|null}  field.placeholder
	 * @returns {HTMLDivElement}
	 * @private
	 */
	_generateError(key, field) {
		let error = document.createElement('div'),
			fieldId = this._getFieldId(key) + '-error';

		error.setAttribute('id', fieldId);
		error.setAttribute('class', 'error-message hidden');

		return error;
	}

	/**
	 * Validate an email field
	 *
	 * @param {FocusEvent} e
	 */
	emailValidateEvent(e) {
		console.log(e);
		let error = document.getElementById(e.target.id + '-error');

		if (e.target.value.trim() !== '') {
			// Input has a value; try to perform basic validation against it

			if (this.emailValidation.test(e.target.value.trim())) {
				e.target.classList.remove('error');
				if (error) {
					error.innerHTML = '';
					error.classList.add('hidden');
				}
			}
			else {
				e.target.classList.add('error');
				if (error) {
					error.innerHTML = 'Email does not appear to be valid';
					error.classList.remove('hidden');
				}
			}
		}
		else {
			// No value set
			if (e.target.required) {
				e.target.classList.add('error');
				if (error) {
					error.innerHTML = 'Field is required';
					error.classList.remove('hidden');
				}
			}
		}
	}

	/**
	 * Validate a generic text field
	 *
	 * @param {FocusEvent} e
	 */
	genericValidateEvent(e) {
		let error = document.getElementById(e.target.id + '-error');

		if (e.target.value.trim() !== '') {
			// Input has a value; no validation available for generic fields

			e.target.classList.remove('error');
			if (error) {
				error.innerHTML = '';
				error.classList.add('hidden');
			}
		}
		else {
			// No value set
			if (e.target.required) {
				e.target.classList.add('error');
				if (error) {
					error.innerHTML = 'Field is required';
					error.classList.remove('hidden');
				}
			}
		}
	}

	/**
	 * Handle form submission via an XHR request
	 *
	 * @param {SubmitEvent} e
	 */
	formSubmitEvent(e) {
		let prevSubmitText = this.submitBtn.value;

		e.preventDefault();

		this.submitBtn.disabled = true;
		this.submitBtn.value = 'Sending...';

		fetch(this.form.action, {
			method: this.form.method,
			headers: {
				'Content-Type': 'application/json'
			},
			body: JSON.stringify(Object.fromEntries(new FormData(this.form)))
		})
			.then(response => {
				// Grab JSON result of submission
				return response.json();
			})
			.then(response => {
				if (!response.success) {
					let errors = response.errors,
						focused = false;

					if (!errors) {
						console.error(response);
						throw new Error('Invalid or unexpected response received from backend');
					}

					Object.keys(errors).forEach(key => {
						let error = document.getElementById([this.getAttribute('name'), key, 'error'].join('-')),
							input = document.getElementById([this.getAttribute('name'), key].join('-'));

						error.innerHTML = errors[key];
						error.classList.remove('hidden');
						input.classList.add('error');
						if (!focused) {
							// Switch focus to the first discovered error
							input.focus();
							focused = true;
						}
					});

					this.submitBtn.disabled = false;
					this.submitBtn.value = prevSubmitText;
				}
				else {
					CMS.log.Debug('Extra/cms-form', 'Received successful response from form handler', response);
					if (this.getAttribute('success')) {
						CMS.historyPushState(CMS.config.webpath + this.getAttribute('success') + '.html');
					}
					else {
						this.submitBtn.disabled = false;
						this.submitBtn.value = prevSubmitText;

						if (response.message) {
							alert(response.message);
						}
						else {
							alert('Form submitted successfully');
						}
					}

				}
			})
			.catch(error => {
				this.submitBtn.disabled = false;
				this.submitBtn.value = prevSubmitText;
				console.error(error);
				alert('There was an error sending your message. Please try again later.');
			});
	}
}

customElements.define('cms-form', CMSFormElement);