UNPKG

@stormid/validate

Version:

Client-side form validation

252 lines (221 loc) 8.9 kB
import { DOTNET_CLASSNAMES, TOKENS } from '../constants'; /** * Hypertext DOM factory function * * @param nodeName [String] * @param attributes [Object] * @param text [String] The innerText of the new node * * @returns node [DOM node] * */ export const h = (nodeName, attributes, text) => { let node = document.createElement(nodeName); for (let prop in attributes) { node.setAttribute(prop, attributes[prop]); } if (text !== undefined && text.length) node.appendChild(document.createTextNode(text)); return node; }; /** * Creates and appends a text node error message to a error container DOM node for a group * * @param group [Object, vaidation group] * @param msg [String] The error message * * @returns node [Text node] * */ export const createErrorTextNode = (group, msg) => { let node = document.createTextNode(msg); group.serverErrorNode.classList.remove(DOTNET_CLASSNAMES.VALID); group.serverErrorNode.classList.add(DOTNET_CLASSNAMES.ERROR); return group.serverErrorNode.appendChild(node); }; /** * Removes the error message, updates .NET MVC error span classNames and deletes the * error from local errors tracking object * * Signature () => groupName => state => {} * (groupName for ease of use as eventListener and in whole form iteration) * * @param groupName [String, vaidation group] * @param state [Object, validation state] * */ export const clearError = groupName => state => { if (state.groups[groupName].serverErrorNode) { state.groups[groupName].serverErrorNode.innerHTML = ''; state.groups[groupName].serverErrorNode.classList.remove(DOTNET_CLASSNAMES.ERROR); state.groups[groupName].serverErrorNode.classList.add(DOTNET_CLASSNAMES.VALID); } else { state.errors[groupName].parentNode.removeChild(state.errors[groupName]); } state.groups[groupName].fields.forEach(field => { field.parentNode.classList.remove('is--invalid'); field.removeAttribute('aria-invalid'); const describedbyid = ((state.groups[groupName].serverErrorNode || state.errors[groupName]).id); //check whether the aria-describedby matches the id, if not another id must be present, only replace the removed error id if (field.hasAttribute('aria-describedby')) { if (field.getAttribute('aria-describedby') === describedbyid) field.removeAttribute('aria-describedby'); else field.setAttribute('aria-describedby', field.getAttribute('aria-describedby').replace(` ${describedbyid}`, '')); } }); delete state.errors[groupName];//shouldn't be doing this here... }; /** * Iterates over all errors in local scope to remove each error prior to re-validation * * @param state [Object, validation state] * */ export const clearErrors = state => { state.errors && Object.keys(state.errors).forEach(name => { clearError(name)(state); }); }; /** * Iterates over all groups to render each error post-vaidation * * @param state [Object, validation state] * */ export const renderErrors = state => { Object.keys(state.groups).forEach(groupName => { if (!state.groups[groupName].valid) renderError(groupName)(state); }); }; /** * Looks for any value tokens and replaces them within the error message * * @param state [Object, validation state] * @param groupName [String, validation group] * */ export const updateMessageValues = (state, groupName) => { let msg = state.groups[groupName].errorMessages[0]; let values = state.groups[groupName].fields.reduce((newMsg, field, index, array) => { if (index === array.length-1) return newMsg + field.value; return newMsg = field.value + ', '; }, ''); return msg.replace(TOKENS.VALUE, values); }; /** * Adds an error message to the DOM and saves it to local scope * * If .NET MVC error span is present, it is used with a appended textNode, * if not a new DOM node is created * * Signature () => groupName => state => {} * (groupName for ease of use as eventListener and in whole form iteration) * * @param groupName [String, validation group] * @param state [Object, validation state] * */ export const renderError = groupName => state => { if (state.errors[groupName]) clearError(groupName)(state); let msg = updateMessageValues(state, groupName); //shouldn't be updating state here... //to do: refactor to update state as a side effect afterwards? //would need to pass store instead of state if (state.groups[groupName].serverErrorNode) { state.errors[groupName] = createErrorTextNode(state.groups[groupName], msg); } else { //No server error node found, so attempt to render inside the label. If no label found, log error to console. const label = document.querySelector(`[for="${state.groups[groupName].fields[state.groups[groupName].fields.length-1].getAttribute('id')}"]`); if (label !== null) { state.errors[groupName] = label.parentNode.insertBefore(h('span', { class: DOTNET_CLASSNAMES.ERROR, id: `${groupName}-error-message` }, msg), label.nextSibling); } else { console.error(`No matching HTML label or server error node found for validation group: ${groupName}. Error message: '${msg}' cannot be displayed. Form will not be submitted.`); return; } } const errorContainer = state.groups[groupName].serverErrorNode || state.errors[groupName]; state.groups[groupName].fields.forEach(field => { field.parentNode.classList.add('is--invalid'); field.setAttribute('aria-invalid', 'true'); if (!field.hasAttribute('aria-describedby') || !hasAriaDescribedbyValue(field, errorContainer.getAttribute('id'))) { field.setAttribute('aria-describedby', (field.hasAttribute('aria-describedby') ? `${field.getAttribute('aria-describedby')} ${errorContainer.getAttribute('id')}` : errorContainer.getAttribute('id')) ); } }); }; export const hasAriaDescribedbyValue = (field, value) => { const describedby = field.getAttribute('aria-describedby').split(' '); return describedby.length > 0 && describedby.reduce((acc, curr) => (acc || curr === value), false); }; /** * Set focus on first invalid field after form-level validate() * * We can assume that there is a group in an invalid state, * and that the group has at least one field * * @param groups [Object, validation group slice of state] * */ export const focusFirstInvalidField = state => { const firstInvalid = Object.keys(state.groups).reduce((acc, curr) => { if (!acc && !state.groups[curr].valid) acc = state.groups[curr].fields[0]; return acc; }, false); firstInvalid && firstInvalid.focus(); }; /** * Creates a hidden field duplicate of a given field, for conferring submit button values * * @param source [Node] A submit input/button * @param form [Node] A form node * */ export const createButtonValueNode = (source, form) => { const node = document.createElement('input'); node.setAttribute('type', 'hidden'); node.setAttribute('name', source.getAttribute('name')); node.setAttribute('value', source.getAttribute('value')); return form.appendChild(node); }; /** * Removes the node added in createButtonValueNode * * @param node [Node] A hidden input * */ export const cleanupButtonValueNode = node => { node.parentNode.removeChild(node); }; /** * Add aria-required attribute to fields if appropriate (has required/data-val-required, is not a checkbox or radio group) * * @param fields [Array of DOMElements] * * @returns fields */ export const addAriaRequired = fields => { fields.forEach(field => { if ( (field.hasAttribute('required') || field.hasAttribute('data-val-required')) && ((field.getAttribute('type') !== 'radio' && field.getAttribute('type') !== 'checkbox') || (field.getAttribute('type') === 'checkbox' && fields.length === 1)) ) { field.setAttribute('aria-required', 'true'); } }); return fields; }; /** * Adds attributes to input and error nodes to help accessibility * * @param state [Object] */ export const addAXAttributes = state => { Object.keys(state.groups).forEach(groupName => { //ensure error message has an id for aria-describedby if (state.groups[groupName].serverErrorNode && !state.groups[groupName].serverErrorNode.hasAttribute('id')) state.groups[groupName].serverErrorNode.setAttribute('id', `${groupName}-error-message`); //Add aria-required to inputs that are not radios, nor checkbox groups (single checkbox gets the attribute added) addAriaRequired(state.groups[groupName].fields); }); };