access-nyc-patterns
Version:
User Interface Patterns for Benefits Access
679 lines (551 loc) • 21.2 kB
JavaScript
'use strict';
/**
* A simple form validation function that uses native form validation. It will
* add appropriate form feedback for each input that is invalid and native
* localized browser messaging.
*
* See https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation
* See https://caniuse.com/#feat=form-validation for support
*
* @param {Event} event The form submission event.
* @param {Array} STRINGS set of strings
* @return {Event/Boolean} The original event or false if invalid.
*/
function valid (event, STRINGS) {
event.preventDefault();
if (process.env.NODE_ENV !== 'production') // eslint-disable-next-line no-console
{
console.dir({
init: 'Validation',
event: event
});
}
var validity = event.target.checkValidity();
var elements = event.target.querySelectorAll('input[required="true"]');
for (var i = 0; i < elements.length; i++) {
// Remove old messaging if it exists
var el = elements[i];
var container = el.parentNode;
var message = container.querySelector('.error-message');
container.classList.remove('error');
if (message) {
message.remove();
} // If this input valid, skip messaging
if (el.validity.valid) {
continue;
} // Create the new error message.
message = document.createElement('div'); // Get the error message from localized strings.
if (el.validity.valueMissing) {
message.innerHTML = STRINGS.VALID_REQUIRED;
} else if (!el.validity.valid) {
message.innerHTML = STRINGS["VALID_" + el.type.toUpperCase() + "_INVALID"];
} else {
message.innerHTML = el.validationMessage;
}
message.setAttribute('aria-live', 'polite');
message.classList.add('error-message'); // Add the error class and error message.
container.classList.add('error');
container.insertBefore(message, container.childNodes[0]);
}
if (process.env.NODE_ENV !== 'production') // eslint-disable-next-line no-console
{
console.dir({
complete: 'Validation',
valid: validity,
event: event
});
}
return validity ? event : validity;
}
/**
* Map toggled checkbox values to an input.
* @param {Object} event The parent click event.
* @return {Element} The target element.
*/
function joinValues (event) {
if (!event.target.matches('input[type="checkbox"]')) {
return;
}
if (!event.target.closest('[data-js-join-values]')) {
return;
}
var el = event.target.closest('[data-js-join-values]');
var target = document.querySelector(el.dataset.jsJoinValues);
target.value = Array.from(el.querySelectorAll('input[type="checkbox"]')).filter(function (e) {
return e.value && e.checked;
}).map(function (e) {
return e.value;
}).join(', ');
return target;
}
// get successful control from form and assemble into object
// http://www.w3.org/TR/html401/interact/forms.html#h-17.13.2
// types which indicate a submit action and are not successful controls
// these will be ignored
var k_r_submitter = /^(?:submit|button|image|reset|file)$/i;
// node names which could be successful controls
var k_r_success_contrls = /^(?:input|select|textarea|keygen)/i;
// Matches bracket notation.
var brackets = /(\[[^\[\]]*\])/g;
// serializes form fields
// @param form MUST be an HTMLForm element
// @param options is an optional argument to configure the serialization. Default output
// with no options specified is a url encoded string
// - hash: [true | false] Configure the output type. If true, the output will
// be a js object.
// - serializer: [function] Optional serializer function to override the default one.
// The function takes 3 arguments (result, key, value) and should return new result
// hash and url encoded str serializers are provided with this module
// - disabled: [true | false]. If true serialize disabled fields.
// - empty: [true | false]. If true serialize empty fields
function serialize(form, options) {
if (typeof options != 'object') {
options = { hash: !!options };
}
else if (options.hash === undefined) {
options.hash = true;
}
var result = (options.hash) ? {} : '';
var serializer = options.serializer || ((options.hash) ? hash_serializer : str_serialize);
var elements = form && form.elements ? form.elements : [];
//Object store each radio and set if it's empty or not
var radio_store = Object.create(null);
for (var i=0 ; i<elements.length ; ++i) {
var element = elements[i];
// ingore disabled fields
if ((!options.disabled && element.disabled) || !element.name) {
continue;
}
// ignore anyhting that is not considered a success field
if (!k_r_success_contrls.test(element.nodeName) ||
k_r_submitter.test(element.type)) {
continue;
}
var key = element.name;
var val = element.value;
// we can't just use element.value for checkboxes cause some browsers lie to us
// they say "on" for value when the box isn't checked
if ((element.type === 'checkbox' || element.type === 'radio') && !element.checked) {
val = undefined;
}
// If we want empty elements
if (options.empty) {
// for checkbox
if (element.type === 'checkbox' && !element.checked) {
val = '';
}
// for radio
if (element.type === 'radio') {
if (!radio_store[element.name] && !element.checked) {
radio_store[element.name] = false;
}
else if (element.checked) {
radio_store[element.name] = true;
}
}
// if options empty is true, continue only if its radio
if (val == undefined && element.type == 'radio') {
continue;
}
}
else {
// value-less fields are ignored unless options.empty is true
if (!val) {
continue;
}
}
// multi select boxes
if (element.type === 'select-multiple') {
val = [];
var selectOptions = element.options;
var isSelectedOptions = false;
for (var j=0 ; j<selectOptions.length ; ++j) {
var option = selectOptions[j];
var allowedEmpty = options.empty && !option.value;
var hasValue = (option.value || allowedEmpty);
if (option.selected && hasValue) {
isSelectedOptions = true;
// If using a hash serializer be sure to add the
// correct notation for an array in the multi-select
// context. Here the name attribute on the select element
// might be missing the trailing bracket pair. Both names
// "foo" and "foo[]" should be arrays.
if (options.hash && key.slice(key.length - 2) !== '[]') {
result = serializer(result, key + '[]', option.value);
}
else {
result = serializer(result, key, option.value);
}
}
}
// Serialize if no selected options and options.empty is true
if (!isSelectedOptions && options.empty) {
result = serializer(result, key, '');
}
continue;
}
result = serializer(result, key, val);
}
// Check for all empty radio buttons and serialize them with key=""
if (options.empty) {
for (var key in radio_store) {
if (!radio_store[key]) {
result = serializer(result, key, '');
}
}
}
return result;
}
function parse_keys(string) {
var keys = [];
var prefix = /^([^\[\]]*)/;
var children = new RegExp(brackets);
var match = prefix.exec(string);
if (match[1]) {
keys.push(match[1]);
}
while ((match = children.exec(string)) !== null) {
keys.push(match[1]);
}
return keys;
}
function hash_assign(result, keys, value) {
if (keys.length === 0) {
result = value;
return result;
}
var key = keys.shift();
var between = key.match(/^\[(.+?)\]$/);
if (key === '[]') {
result = result || [];
if (Array.isArray(result)) {
result.push(hash_assign(null, keys, value));
}
else {
// This might be the result of bad name attributes like "[][foo]",
// in this case the original `result` object will already be
// assigned to an object literal. Rather than coerce the object to
// an array, or cause an exception the attribute "_values" is
// assigned as an array.
result._values = result._values || [];
result._values.push(hash_assign(null, keys, value));
}
return result;
}
// Key is an attribute name and can be assigned directly.
if (!between) {
result[key] = hash_assign(result[key], keys, value);
}
else {
var string = between[1];
// +var converts the variable into a number
// better than parseInt because it doesn't truncate away trailing
// letters and actually fails if whole thing is not a number
var index = +string;
// If the characters between the brackets is not a number it is an
// attribute name and can be assigned directly.
if (isNaN(index)) {
result = result || {};
result[string] = hash_assign(result[string], keys, value);
}
else {
result = result || [];
result[index] = hash_assign(result[index], keys, value);
}
}
return result;
}
// Object/hash encoding serializer.
function hash_serializer(result, key, value) {
var matches = key.match(brackets);
// Has brackets? Use the recursive assignment function to walk the keys,
// construct any missing objects in the result tree and make the assignment
// at the end of the chain.
if (matches) {
var keys = parse_keys(key);
hash_assign(result, keys, value);
}
else {
// Non bracket notation can make assignments directly.
var existing = result[key];
// If the value has been assigned already (for instance when a radio and
// a checkbox have the same name attribute) convert the previous value
// into an array before pushing into it.
//
// NOTE: If this requirement were removed all hash creation and
// assignment could go through `hash_assign`.
if (existing) {
if (!Array.isArray(existing)) {
result[key] = [ existing ];
}
result[key].push(value);
}
else {
result[key] = value;
}
}
return result;
}
// urlform encoding serializer
function str_serialize(result, key, value) {
// encode newlines as \r\n cause the html spec says so
value = value.replace(/(\r)?\n/g, '\r\n');
value = encodeURIComponent(value);
// spaces should be '+' rather than '%20'.
value = value.replace(/%20/g, '+');
return result + (result ? '&' : '') + encodeURIComponent(key) + '=' + value;
}
var formSerialize = serialize;
/**
* The Newsletter module
* @class
*/
var Newsletter = function Newsletter(element) {
var this$1 = this;
this._el = element;
this.STRINGS = Newsletter.strings; // Map toggled checkbox values to an input.
this._el.addEventListener('click', joinValues); // This sets the script callback function to a global function that
// can be accessed by the the requested script.
window[Newsletter.callback] = function (data) {
this$1._callback(data);
};
this._el.querySelector('form').addEventListener('submit', function (event) {
return valid(event, this$1.STRINGS) ? this$1._submit(event).then(this$1._onload)["catch"](this$1._onerror) : false;
});
return this;
};
/**
* The form submission method. Requests a script with a callback function
* to be executed on our page. The callback function will be passed the
* response as a JSON object (function parameter).
* @param{Event} event The form submission event
* @return {Promise} A promise containing the new script call
*/
Newsletter.prototype._submit = function _submit(event) {
event.preventDefault(); // Serialize the data
this._data = formSerialize(event.target, {
hash: true
}); // Switch the action to post-json. This creates an endpoint for mailchimp
// that acts as a script that can be loaded onto our page.
var action = event.target.action.replace(Newsletter.endpoints.MAIN + "?", Newsletter.endpoints.MAIN_JSON + "?"); // Add our params to the action
action = action + formSerialize(event.target, {
serializer: function serializer() {
var params = [],
len = arguments.length;
while (len--) {
params[len] = arguments[len];
}
var prev = typeof params[0] === 'string' ? params[0] : '';
return prev + "&" + params[1] + "=" + params[2];
}
}); // Append the callback reference. Mailchimp will wrap the JSON response in
// our callback method. Once we load the script the callback will execute.
action = action + "&c=window." + Newsletter.callback; // Create a promise that appends the script response of the post-json method
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
document.body.appendChild(script);
script.onload = resolve;
script.onerror = reject;
script.async = true;
script.src = encodeURI(action);
});
};
/**
* The script onload resolution
* @param{Event} event The script on load event
* @return {Class} The Newsletter class
*/
Newsletter.prototype._onload = function _onload(event) {
event.path[0].remove();
return this;
};
/**
* The script on error resolution
* @param{Object} error The script on error load event
* @return {Class} The Newsletter class
*/
Newsletter.prototype._onerror = function _onerror(error) {
// eslint-disable-next-line no-console
if (process.env.NODE_ENV !== 'production') {
console.dir(error);
}
return this;
};
/**
* The callback function for the MailChimp Script call
* @param{Object} data The success/error message from MailChimp
* @return {Class} The Newsletter class
*/
Newsletter.prototype._callback = function _callback(data) {
if (this["_" + data[this._key('MC_RESULT')]]) {
this["_" + data[this._key('MC_RESULT')]](data.msg);
} else // eslint-disable-next-line no-console
if (process.env.NODE_ENV !== 'production') {
console.dir(data);
}
return this;
};
/**
* Submission error handler
* @param{string} msg The error message
* @return {Class} The Newsletter class
*/
Newsletter.prototype._error = function _error(msg) {
this._elementsReset();
this._messaging('WARNING', msg);
return this;
};
/**
* Submission success handler
* @param{string} msg The success message
* @return {Class} The Newsletter class
*/
Newsletter.prototype._success = function _success(msg) {
this._elementsReset();
this._messaging('SUCCESS', msg);
return this;
};
/**
* Present the response message to the user
* @param{String} type The message type
* @param{String} msgThe message
* @return {Class} Newsletter
*/
Newsletter.prototype._messaging = function _messaging(type, msg) {
if (msg === void 0) msg = 'no message';
var strings = Object.keys(Newsletter.stringKeys);
var handled = false;
var alertBox = this._el.querySelector(Newsletter.selectors[type + "_BOX"]);
var alertBoxMsg = alertBox.querySelector(Newsletter.selectors.ALERT_BOX_TEXT); // Get the localized string, these should be written to the DOM already.
// The utility contains a global method for retrieving them.
for (var i = 0; i < strings.length; i++) {
if (msg.indexOf(Newsletter.stringKeys[strings[i]]) > -1) {
msg = this.STRINGS[strings[i]];
handled = true;
}
} // Replace string templates with values from either our form data or
// the Newsletter strings object.
for (var x = 0; x < Newsletter.templates.length; x++) {
var template = Newsletter.templates[x];
var key = template.replace('{{ ', '').replace(' }}', '');
var value = this._data[key] || this.STRINGS[key];
var reg = new RegExp(template, 'gi');
msg = msg.replace(reg, value ? value : '');
}
if (handled) {
alertBoxMsg.innerHTML = msg;
} else if (type === 'ERROR') {
alertBoxMsg.innerHTML = this.STRINGS.ERR_PLEASE_TRY_LATER;
}
if (alertBox) {
this._elementShow(alertBox, alertBoxMsg);
}
return this;
};
/**
* The main toggling method
* @return {Class} Newsletter
*/
Newsletter.prototype._elementsReset = function _elementsReset() {
var targets = this._el.querySelectorAll(Newsletter.selectors.ALERT_BOXES);
var loop = function loop(i) {
if (!targets[i].classList.contains(Newsletter.classes.HIDDEN)) {
targets[i].classList.add(Newsletter.classes.HIDDEN);
Newsletter.classes.ANIMATE.split(' ').forEach(function (item) {
return targets[i].classList.remove(item);
}); // Screen Readers
targets[i].setAttribute('aria-hidden', 'true');
targets[i].querySelector(Newsletter.selectors.ALERT_BOX_TEXT).setAttribute('aria-live', 'off');
}
};
for (var i = 0; i < targets.length; i++) {
loop(i);
}
return this;
};
/**
* The main toggling method
* @param{object} targetMessage container
* @param{object} content Content that changes dynamically that should
* be announced to screen readers.
* @return {Class} Newsletter
*/
Newsletter.prototype._elementShow = function _elementShow(target, content) {
target.classList.toggle(Newsletter.classes.HIDDEN);
Newsletter.classes.ANIMATE.split(' ').forEach(function (item) {
return target.classList.toggle(item);
}); // Screen Readers
target.setAttribute('aria-hidden', 'true');
if (content) {
content.setAttribute('aria-live', 'polite');
}
return this;
};
/**
* A proxy function for retrieving the proper key
* @param{string} key The reference for the stored keys.
* @return {string} The desired key.
*/
Newsletter.prototype._key = function _key(key) {
return Newsletter.keys[key];
};
/**
* Setter for the Autocomplete strings
* @param {object}localizedStringsObject containing strings.
* @return{object} The Newsletter Object.
*/
Newsletter.prototype.strings = function strings(localizedStrings) {
Object.assign(this.STRINGS, localizedStrings);
return this;
};
/** @type {Object} API data keys */
Newsletter.keys = {
MC_RESULT: 'result',
MC_MSG: 'msg'
};
/** @type {Object} API endpoints */
Newsletter.endpoints = {
MAIN: '/post',
MAIN_JSON: '/post-json'
};
/** @type {String} The Mailchimp callback reference. */
Newsletter.callback = 'AccessNycNewsletterCallback';
/** @type {Object} DOM selectors for the instance's concerns */
Newsletter.selectors = {
ELEMENT: '[data-js="newsletter"]',
ALERT_BOXES: '[data-js-newsletter*="alert-box-"]',
WARNING_BOX: '[data-js-newsletter="alert-box-warning"]',
SUCCESS_BOX: '[data-js-newsletter="alert-box-success"]',
ALERT_BOX_TEXT: '[data-js-newsletter="alert-box__text"]'
};
/** @type {String} The main DOM selector for the instance */
Newsletter.selector = Newsletter.selectors.ELEMENT;
/** @type {Object} String references for the instance */
Newsletter.stringKeys = {
SUCCESS_CONFIRM_EMAIL: 'Almost finished...',
ERR_PLEASE_ENTER_VALUE: 'Please enter a value',
ERR_TOO_MANY_RECENT: 'too many',
ERR_ALREADY_SUBSCRIBED: 'is already subscribed',
ERR_INVALID_EMAIL: 'looks fake or invalid'
};
/** @type {Object} Available strings */
Newsletter.strings = {
VALID_REQUIRED: 'This field is required.',
VALID_EMAIL_REQUIRED: 'Email is required.',
VALID_EMAIL_INVALID: 'Please enter a valid email.',
VALID_CHECKBOX_BOROUGH: 'Please select a borough.',
ERR_PLEASE_TRY_LATER: 'There was an error with your submission. ' + 'Please try again later.',
SUCCESS_CONFIRM_EMAIL: 'Almost finished... We need to confirm your email ' + 'address. To complete the subscription process, ' + 'please click the link in the email we just sent you.',
ERR_PLEASE_ENTER_VALUE: 'Please enter a value',
ERR_TOO_MANY_RECENT: 'Recipient "{{ EMAIL }}" has too' + 'many recent signup requests',
ERR_ALREADY_SUBSCRIBED: '{{ EMAIL }} is already subscribed' + 'to list {{ LIST_NAME }}.',
ERR_INVALID_EMAIL: 'This email address looks fake or invalid.' + 'Please enter a real email address.',
LIST_NAME: 'ACCESS NYC - Newsletter'
};
/** @type {Array} Placeholders that will be replaced in message strings */
Newsletter.templates = ['{{ EMAIL }}', '{{ LIST_NAME }}'];
Newsletter.classes = {
ANIMATE: 'animated fadeInUp',
HIDDEN: 'hidden'
};
module.exports = Newsletter;