@financial-times/o-forms
Version:
This component provides responsive styling for form fields and inputs. It provides validation and error handling for forms, as well.
278 lines (248 loc) • 9.39 kB
JavaScript
import Input from './input.js';
import State from './state.js';
import ErrorSummary from './error-summary.js';
class Forms {
/**
* @typedef {Object} FormsOptions - An options object for configuring the form.
* @property {boolean} [errorSummary=true] - Display an error summary at the top of the form as part of `o-forms` validation.
* @property {boolean} [preventSubmit=false] - Prevent form submission after `o-froms` validation – see the `oForms.submit` event to manually submit the form after validation. This does not apply when `useBrowserValidation` is true.
* @property {boolean} [useBrowserValidation=false] - Do not use `o-forms` validation, rely on the browser's built-in validation instead.
*/
/**
* @typedef {Event} FormsSubmitEvent - An event emitted when the form is submitted by the userand `o-forms` has completed validation.
* @property {object} detail - The event detail.
* @property {object} detail.instance - The instance of `o-forms`.
* @property {boolean} detail.valid - The validity of the `o-forms` instance.
*/
/**
* Class constructor.
*
* @param {HTMLFormElement} [formElement] - The form element in the DOM
* @param {FormsOptions} [options={}] - An options object for configuring the form
*/
constructor(formElement, options) {
if (!formElement || formElement.nodeName !== 'FORM') {
throw new Error(`[data-o-component="o-forms"] must be set on a form element. It is currently set on a '${formElement.nodeName.toLowerCase()}'.`);
}
this.form = formElement;
this._formInputsCache = Array.from(this.form.elements, element => new Input(element));
this.stateElements = [];
this.opts = Object.assign({
useBrowserValidation: false,
preventSubmit: false,
errorSummary: true
}, options || Forms.getDataAttributes(formElement));
if(this.opts.useBrowserValidation && this.opts.preventSubmit) {
throw new Error('The o-forms `preventSubmit` option only applies when the `useBrowserValidation` option is `false`.');
}
if (!this.opts.useBrowserValidation) {
this.form.setAttribute('novalidate', '');
this.form.addEventListener('submit', this);
this.form.addEventListener('oForms.submit', (e) => {
if(e.detail.valid && !this.opts.preventSubmit) {
this.form.submit();
}
});
} else {
this.form.removeAttribute('novalidate');
this.submits = this.form.querySelectorAll('[type=submit]');
this.submits.forEach(submit => {
submit.addEventListener('click', this);
submit.addEventListener('keydown', this);
});
}
}
get formInputs() {
if(!this.form) {
return [];
}
const formElements = Array.from(this.form.elements);
const inputsToRemove = this._formInputsCache.filter(input => !formElements.includes(input.input));
const elementsToAdd = formElements.filter(element => !this._formInputsCache.find((input) => input.input === element));
inputsToRemove.forEach(input => input.destroy());
this._formInputsCache = [
...this._formInputsCache.filter(input => !inputsToRemove.includes(input)),
...elementsToAdd.map(element => new Input(element))
];
return this._formInputsCache;
}
/**
* Get the data attributes from the formElement. If the form is being set up
* declaratively, this method is used to extract the data attributes from the DOM.
*
* @param {HTMLElement} formElement - The message element in the DOM
* @returns {Object.<string, any>} - The options
*/
static getDataAttributes(formElement) {
if (!(formElement instanceof HTMLElement)) {
return {};
}
return Object.keys(formElement.dataset).reduce((options, key) => {
// Ignore data-o-component
if (key === 'oComponent') {
return options;
}
// Build a concise key and get the option value
const shortKey = key.replace(/^oMessage(\w)(\w+)$/, (m, m1, m2) => m1.toLowerCase() + m2);
const value = formElement.dataset[key];
// Try parsing the value as JSON, otherwise just set it as a string
try {
options[shortKey] = JSON.parse(value.replace(/\'/g, '"'));
} catch (error) {
options[shortKey] = value;
}
return options;
}, {});
}
/**
* Event Handler
*
* @param {object} event - The event emitted by element/window interactions
* @returns {void}
*/
handleEvent(event) {
const RETURN_KEY = 13;
if (event.type === 'click' || event.type === 'keydown' && event.key === RETURN_KEY) {
if (this.form.checkValidity() === false) {
this.validateFormInputs();
}
}
if (event.type === 'submit') {
event.preventDefault();
const checkedElements = this.validateFormInputs();
const formInvalid = checkedElements.some(input => input.valid === false);
if (formInvalid) {
// Display error summary.
if (this.opts.errorSummary) {
if (this.summary) {
const newSummary = new ErrorSummary(checkedElements, this.opts.errorSummaryMessage);
this.form.replaceChild(newSummary, this.summary);
this.summary = newSummary;
} else {
this.summary = this.form.insertBefore(new ErrorSummary(checkedElements, this.opts.errorSummaryMessage), this.form.firstElementChild);
}
const firstErrorAnchor = this.summary.querySelector('a');
if (firstErrorAnchor) {
firstErrorAnchor.focus();
}
}
}
/**
* @type {FormsSubmitEvent}
*/
const oFormsSubmitEvent = new CustomEvent('oForms.submit', {
detail: {
instance: this,
valid: !formInvalid
},
cancelable: true,
bubbles: true
});
this.form.dispatchEvent(oFormsSubmitEvent);
}
}
/**
* @typedef {object} ErrorSummaryElement
* @property {HTMLInputElement} element - the associated element
* @property {string} id - the input element's id
* @property {boolean} valid - was the user's value valid?
* @property {string=} error - the error message for this element
* @property {HTMLElement=} field - a containing o-forms-field element
* @property {HTMLLabelElement} label - an associated label element
*/
/**
* Form validation
* Validates every element in the form and creates input objects for the error
summary
*
* @returns {Array<ErrorSummaryElement>} - list of elements for the error summary
*/
validateFormInputs() {
return this.formInputs.map(oFormInput => {
const valid = oFormInput.validate();
const input = oFormInput.input;
const field = input.closest('.o-forms-field');
const labelElement = field ? field.querySelector('.o-forms-title__main') : null;
// label is actually the field title, not for example the label of a single checkbox.
// this is used to generate an error summary
const label = labelElement ? labelElement.textContent : null;
const errorElement = field ? field.querySelector('.o-forms-input__error') : null;
const error = errorElement ? errorElement.textContent : input.validationMessage;
return {
id: input.id,
valid,
error: !valid ? error : null,
label,
field,
element: input
};
});
}
/**
* Input state
*
* @param {string} [name] - name of the input fields to add state to
* @param {string} [state] - type of state to apply — one of 'saving', 'saved', 'none'
* @param {boolean|object} [options] - an object of options display an icon only when true, hiding the status label
*/
/**
*
* @param {string} state - name of the input fields to add state to
* @param {string} name - type of state to apply — one of 'saving', 'saved', 'none'
* @param {object} options - an object of options
* @param {string} options.iconLabel [null] - customise the label of the state, e.g. the saved state defaults to "Saving" but could be "Sent"
* @param {boolean} options.iconOnly [false] - when true display an icon only, hiding the status label
*/
setState(state, name, options = { iconLabel: null, iconOnly: false }) {
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
throw new Error('The `options` argument must be an object');
}
let object = this.stateElements.find(item => item.name === name);
if (!object) {
object = {
name,
element: new State(this.form.elements[name], options)
};
this.stateElements.push(object);
}
object.element.set(state, options.iconLabel);
}
/**
* Destroy form instance
*/
destroy() {
if (!this.opts.useBrowserValidation) {
this.form.removeEventListener('submit', this);
} else {
this.submits.forEach(submit => {
submit.removeEventListener('click', this);
submit.removeEventListener('keydown', this);
});
}
this.form = null;
this._formInputsCache.forEach(input => input.destroy());
this._formInputsCache = [];
this.stateElements = null;
this.opts = null;
}
/**
* Initialise form component.
*
* @param {(HTMLElement | string)} rootEl - The root element to intialise a form in, or a CSS selector for the root element
* @param {object} [opts={}] - An options object for configuring the banners
* @returns {Forms | Forms[]} - The newly instantiated Form or Forms
*/
static init(rootEl, opts) {
if (!rootEl) {
rootEl = document.body;
}
if (!(rootEl instanceof HTMLElement)) {
rootEl = document.querySelector(rootEl);
}
if (rootEl instanceof HTMLFormElement) {
return new Forms(rootEl, opts);
}
return Array.from(rootEl.querySelectorAll('[data-o-component="o-forms"]'), rootEl => new Forms(rootEl, opts));
}
}
export default Forms;