form-js
Version:
Create better ui form elements. Supports IE9+, all modern browsers, and mobile.
505 lines (453 loc) • 17.7 kB
JavaScript
/* global Platform */
'use strict';
import _ from'underscore';
import Dropdown from'./dropdown';
import InputField from'./input-field';
import Checkbox from'./checkbox';
import Checkboxes from'./checkboxes';
import FormElement from'./form-element';
import FormElementGroup from'./form-element-group';
import Radios from'./radios';
import TextArea from'./text-area';
import SubmitButton from'./submit-button';
import ObserveJS from 'observe-js';
/**
* The function that fires when the input value changes
* @callback Form~onValueChange
* @param {string} value - The new value
* @param {HTMLElement} element - The form element
* @param {HTMLElement} uIElement - The ui-version of the form element
*/
/**
* The function that fires to give users opportunity to return a custom set of options on a per-element basis
* @callback Form~onGetOptions
* @param {HTMLElement} element - The element on which to use the custom options
* @returns {Object} Return the custom set of options
*/
/**
* The function that fires when the submit button is clicked
* @callback Form~onSubmitButtonClick
* @params {Event} e - The click event
*/
/**
* The function that fires when the form is submitted.
* @callback Form~onSubmit
* @params {Event} e - The click event
* @params {Array} values - An array of the form's current values at the time the form was submitted
*/
/**
* Utility class for form elements.
* @class Form
*/
class Form {
/**
* Sets up the form.
* @param {object} options - The options
* @param {HTMLFormElement} options.el - The form element
* @param {Form~onValueChange} [options.onValueChange] - A callback function that fires when the value of any form element changes
* @param {Function} [options.onGetOptions] - Function callback that is fired upon instantiation to provide custom options
* @param {string} [options.dropdownClass] - The css class used to query the set of dropdown elements that should be included
* @param {string} [options.checkboxClass] - The css class used to query the set of checkbox elements that should be included
* @param {string} [options.inputFieldClass] - The css class used to query the set of text input elements that should be included
* @param {string} [options.textAreaClass] - The css class used to query the set of textarea elements that should be included
* @param {string} [options.radioClass] - The css class used to query the set of radio button elements that should be included
* @param {Form~onSubmit} [options.onSubmit] - Function that is called when the form is submitted
* @param {string} [options.submitButtonClass] - The css class used to query the submit button
* @param {string} [options.submitButtonDisabledClass] - The class that will be applied to the submit button when its disabled
* @param {Form~onSubmitButtonClick} [options.onSubmitButtonClick] - Function that is called when the submit button is clicked
* @param {Object} [options.data] - An object mapping the form elements name attributes (keys) to their values which will be binded to form's fields
* @param {Number} [options.legacyDataPollTime] - The amount of time (in milliseconds) to poll for options.data changes for browsers that do not support native data observing
*/
constructor (options) {
options = _.extend({
el: null,
onValueChange: null,
onGetOptions: null,
dropdownClass: null,
checkboxClass: null,
inputFieldClass: null,
textAreaClass: null,
radioClass: null,
onSubmit: null,
submitButtonClass: null,
submitButtonDisabledClass: null,
onSubmitButtonClick: null,
data: null,
legacyDataPollTime: 125
}, options);
this.options = options;
// okay to cache here because its a "live" html collection -- yay!
this.formEls = this.options.el.elements;
this._formInstances = [];
this._moduleCount = 0;
this.subModules = {};
this._onSubmitEventListener = this.onSubmit.bind(this);
this.options.el.addEventListener('submit', this._onSubmitEventListener, true);
}
/**
* Sets up data map so that we're observing its changes.
* @returns {Object}
* @private
*/
_setupDataMapping (rawData) {
var data = {};
if (rawData) {
data = rawData;
// if Object.observe is not supported, we poll data every 125 milliseconds
if (!Object.observe) {
this._legacyDataPollTimer = window.setInterval(function () {
Platform.performMicrotaskCheckpoint();
}, this.options.legacyDataPollTime)
}
// sync any changes made on data map to options data
this._observer = new ObserveJS.ObjectObserver(data);
this._observer.open(function (added, removed, changed) {
var mashup = _.extend(added, removed, changed);
Object.keys(mashup).forEach(function(n) {
this.getInstanceByName(n).setValue(mashup[n]);
}.bind(this));
}.bind(this));
}
return data;
}
/**
* Returns a mapping of ids to their associated form option and selector.
*/
_getSelectorMap () {
return {
dropdown: {
option: this.options.dropdownClass,
selector: 'select',
tag: 'select'
},
checkbox: {
option: this.options.checkboxClass,
tag: 'input',
types: ['checkbox']
},
input: {
option: this.options.inputFieldClass,
tag: 'input',
types: [
'password', 'email', 'number', 'text', 'date',
'datetime', 'month', 'search', 'range', 'time',
'week', 'tel', 'color', 'datetime-local'
]
},
radio: {
option: this.options.radioClass,
tag: 'input',
types: ['radio']
},
textarea: {
option: this.options.textAreaClass,
tag: 'textarea'
}
}
}
onSubmit (e) {
let currentValues = this.getCurrentValues();
if (this.options.onSubmitButtonClick) {
this.options.onSubmitButtonClick(e, currentValues);
}
if (this.options.onSubmit) {
this.options.onSubmit(e, currentValues)
}
}
/**
* Sets up the form and instantiates all necessary element classes.
*/
setup () {
var submitButtonEl = this.options.el.getElementsByClassName(this.options.submitButtonClass)[0];
this._setupInstances(this._getInstanceEls('dropdown'), Dropdown);
this._setupInstances(this._getInstanceEls('checkbox'), Checkbox);
this._setupInstances(this._getInstanceEls('input'), InputField);
this._setupInstances(this._getInstanceEls('textarea'), TextArea);
// group radio button toggles by name before instantiating
var radios = this._getInstanceEls('radio');
_.each(this.mapElementsByAttribute(radios, 'name'), function (els) {
this._setupInstance(els, Radios, {}, 'inputs');
}, this);
if (submitButtonEl) {
this.subModules.submitButton = new SubmitButton({
el: submitButtonEl,
disabledClass: this.options.submitButtonDisabledClass,
onClick: this.onSubmit.bind(this)
});
}
this._setupDataMapping(this.options.data);
}
/**
* Gets the matching form elements, based on the supplied type.
* @param {string} type - The type identifier (i.e. "dropdown", "checkbox", "input")
* @returns {Array|HTMLCollection} Returns an array of matching elements
* @private
*/
_getInstanceEls (type) {
var formEl = this.options.el,
elements = [],
map = this._getSelectorMap();
map = map[type] || {};
// we are strategically grabbing elements by "tagName" to ensure we have a LIVE HTMLCollection
// instead of an ineffective, non-live NodeList (i.e. querySelector), can we say, "less state management"!
if (map.option) {
elements = formEl.getElementsByClassName(map.option);
} else if (map.types) {
map.types.forEach(function (val) {
(this.mapElementsByAttribute(this.formEls, 'type')[val] || []).forEach(function (el) {
elements.push(el);
});
}, this);
} else if (map.tag) {
elements = formEl.getElementsByTagName(map.tag);
}
return elements;
}
/**
* Creates a single instance of a class for each of the supplied elements.
* @param {HTMLCollection|Array} elements - The set of elements to instance the class on
* @param {Function} View - The class to instantiate
* @param {Object} [options] - The options to be passed to instantiation
* @param {string} [elKey] - The key to use as the "el"
* @private
*/
_setupInstances (elements, View, options, elKey) {
var count = elements.length,
i;
if (count) {
for (i = 0; i < count; i++) {
this._setupInstance(elements[i], View, options, elKey);
}
}
}
/**
* Creates a single instance of a class using multiple elements.
* @param {Array|HTMLCollection} els - The elements for which to setup an instance
* @param {Function} View - The class to instantiate
* @param {Object} [options] - The options to be passed to instantiation
* @param {string} [elKey] - The key to use as the "el"
* @private
*/
_setupInstance (els, View, options, elKey) {
elKey = elKey || 'el';
var formOptions = this.options;
var finalOptions = this._buildOptions(els, options);
finalOptions[elKey] = els; // dont allow custom options to override the el!
// assign value to form element if a data object was passed in options
els = els.length ? Array.prototype.slice.call(els) : [els]; //ensure array
var name = els[0].name;
if (formOptions.data && typeof formOptions.data[name] !== 'function' && formOptions.data.hasOwnProperty(name)) {
finalOptions.value = finalOptions.value || formOptions.data[name];
}
this._moduleCount++;
var instance = this.subModules['fe' + this._moduleCount] = new View(finalOptions);
this._formInstances.push(instance);
}
/**
* Maps all supplied elements by an attribute.
* @param {Array|HTMLCollection|NodeList} elements
* @param {string} attr - The attribute to map by (the values will be the keys in the map returned)
* @returns {Object} Returns the final object
*/
mapElementsByAttribute (elements, attr) {
var map = {},
count = elements.length,
i,
el;
if (count) {
for (i = 0; i < count; i++) {
el = elements[i];
if (map[el[attr]]) {
map[el[attr]].push(el);
} else {
map[el[attr]] = [el];
}
}
}
return map;
}
/**
* Returns the instance (if there is one) of an element with a specified name attribute
* @param {string} name - The name attribute of the element whos instance is desired
* @returns {Object} Returns the instance that matches the name specified
* @TODO: this method should return an array because there could be multiple form elements with the same name!
*/
getInstanceByName (name) {
var i,
instance;
for (i = 0; i < this._formInstances.length; i++) {
instance = this._formInstances[i];
if (instance.getFormElement().name === name) {
break;
}
}
return instance;
}
/**
* Builds the initialize options for an element.
* @param {HTMLElement|Array} el - The element (or if radio buttons, an array of elements)
* @param {Object} options - The beginning set of options
* @returns {*|{}}
* @private
*/
_buildOptions (el, options) {
options = options || {};
if (this.options.onGetOptions) {
options = _.extend({}, options, this.options.onGetOptions(el));
}
options.onChange = function (value, inputEl, UIElement) {
this._onValueChange(value, inputEl, UIElement);
}.bind(this);
return options;
}
/**
* When any form element's value changes.
* @param {string} value - The new value
* @param {HTMLElement} el - The element that triggered value change
* @param {HTMLElement} ui - The UI version of the element
* @private
*/
_onValueChange (value, el, ui) {
var name = el.name,
formOptionsData = this.options.data || {},
mapValue = formOptionsData[name];
// update data map
if (typeof mapValue === 'function') {
// function, so call it
mapValue(value);
} else if (formOptionsData.hasOwnProperty(name)) {
formOptionsData[name] = value;
}
if (this.options.onValueChange) {
this.options.onValueChange(value, el, ui);
}
if (this.options.onChange) {
this.options.onChange(value, el, ui);
}
}
/**
* Disables all form elements.
*/
disable () {
var els = this.formEls,
i,
submitButton = this.getSubmitButtonInstance();
this.setPropertyAll('disabled', true);
// add disabled css classes
for (i = 0; i < els.length; i++) {
els[i].classList.add('disabled');
}
if (submitButton) {
submitButton.disable();
}
}
/**
* Enables all form elements.
*/
enable () {
var els = this.formEls,
i,
submitButton = this.getSubmitButtonInstance();
this.setPropertyAll('disabled', false);
// remove disabled css classes
for (i = 0; i < els.length; i++) {
els[i].classList.remove('disabled');
}
if (submitButton) {
submitButton.disable();
}
}
/**
* Sets a property on all form elements.
* @TODO: this function still exists until this class can cover ALL possible form elements (i.e. radio buttons)
* @param {string} prop - The property to change
* @param {*} value - The value to set
*/
setPropertyAll (prop, value) {
var i,
els = this.formEls;
for (i = 0; i < els.length; i++) {
els[i][prop] = value;
}
}
/**
* Triggers a method on all form instances.
* @param {string} method - The method
* @param {...*} params - Any params for the method from here, onward
*/
triggerMethodAll (method, params) {
var args = Array.prototype.slice.call(arguments, 1),
i, instance;
for (i = 0; i < this._formInstances.length; i++) {
instance = this._formInstances[i];
instance[method].apply(instance, args);
}
}
/**
* Clears all form items.
*/
clear () {
this.triggerMethodAll('clear');
}
/**
* Gets an object that maps all fields to their current name/value pairs.
* @returns {Array} Returns an array of objects
*/
getCurrentValues () {
var map = [],
fields = this.options.el.querySelectorAll('[name]'),
fieldCount = fields.length,
i,
field,
obj;
for (i = 0; i < fieldCount; i++) {
field = fields[i];
// only add fields with a name attribute
if (field.name) {
obj = {
name: field.name,
// fallback to value attribute when .value can't be trusted (i.e. input[type=date])
value: field.value,
//value: field.value || field.getAttribute('value')
required: field.required,
disabled: field.disabled,
formElement: field
};
map.push(obj);
}
}
return map;
}
/**
* Returns the submit button instance.
* @returns {Object}
*/
getSubmitButtonInstance () {
return this.subModules.submitButton;
}
/**
* Cleans up some stuff.
*/
destroy () {
this.options.el.removeEventListener('submit', this._onSubmitEventListener, true);
window.clearInterval(this._legacyDataPollTimer);
if (this._observer) {
this._observer.close();
}
for (let key in this.subModules) {
if (this.subModules.hasOwnProperty(key) && this.subModules[key]) {
this.subModules[key].destroy();
}
}
}
}
Form.Checkbox = Checkbox;
Form.Checkboxes = Checkboxes;
Form.Dropdown = Dropdown;
Form.FormElement = FormElement;
Form.FormElementGroup = FormElementGroup;
Form.InputField = InputField;
Form.Radios = Radios;
Form.SubmitButton = SubmitButton;
Form.TextArea = TextArea;
module.exports = Form;