UNPKG

form-js

Version:

Create better ui form elements. Supports IE9+, all modern browsers, and mobile.

756 lines (665 loc) 27.1 kB
'use strict'; import _ from 'underscore'; import FormElement from './form-element'; import DeviceManager from 'device-manager'; /** * The function that is triggered the selected dropdown value changes * @callback Dropdown~onChange * @param {HTMLSelectElement} input - The select element after its value has been updated * @param {HTMLElement} UIElement - The container of the select element after its value has been updated * @param {Event} event - The event */ /** * The function that is triggered the dropdown gains focus * @callback Dropdown~onFocus * @param {Event} event - The event */ /** * The function that is triggered the dropdown loses focus * @callback Dropdown~onBlur * @param {Event} event - The event */ /** * Adds JS functionality to a select element and creates a ui representation of it to allow custom styling. * Falls back to native dropdowns on mobile devices. * @constructor Dropdown */ class Dropdown extends FormElement { /** * When instantiated. * @param options * @param {HTMLSelectElement} options.el - The container of the dropdown * @param {Dropdown~onChange} [options.onChange] - A callback function that fires when the selected dropdown value changes * @param {Boolean} [options.autoSetup] - When to automatically setup the dropdown (add event listeners, etc) * @param {Dropdown~onFocus} [options.onFocus] - When the dropdown gains focus * @param {Dropdown~onBlur} [options.onBlur] - When the dropdown loses focus * @param {string} [options.customWrapperClass] - The css class to use for div that the select element and the generated UI version of the dropdown will be wrapped by * @param {string} [options.containerClass] - The css class to use for the dropdown container for the ui representation of the dropdown * @param {string} [options.optionsContainerClass] - The css class to use for the options container of the ui representation of the dropdown * @param {string} [options.optionsContainerActiveClass] - The css class that will applied to the ui representation of an option element when it should be visible to the user * @param {string} [options.optionsClass] - The css class to use for the ui representation of all options elements * @param {string} [options.optionsSelectedClass] - The css class to use for the option element of the ui representation of the dropdown when it is selected * @param {string} [options.selectedValueContainerClass] - The css class to use for the selected value container of the dropdown * @param {string} [options.selectedValueContainerActiveClass] - The css class that will be applied to the selected value container when it should be visible to the user */ constructor (options) { options = _.extend({ el: null, onChange: null, autoSetup: true, onFocus: null, onBlur: null, customWrapperClass: 'dropdown-wrapper', containerClass: 'dropdown-container', optionsContainerClass: 'dropdown-option-container', optionsContainerActiveClass: 'dropdown-option-container-active', optionsClass: 'dropdown-option', optionsHighlightedClass: 'dropdown-option-highlighted', optionsSelectedClass: 'dropdown-option-selected', selectedValueContainerClass: 'dropdown-value-container', selectedValueContainerActiveClass: 'dropdown-value-container-active', disabledClass: 'dropdown-disabled' }, options); super(options); this.options = options; this._keyMap = { 38: 'up', 40: 'down', 27: 'esc', 32: 'space' }; if (this.options.autoSetup) { this.setup(); } } /** * Sets up events for dropdown. * @memberOf Dropdown */ setup () { var el = this.options.el, selectedOption = el.querySelectorAll('option[selected]')[0]; this.addEventListener(el, 'change', '_onSelectChange', this); this._wrapperEl = this._buildWrapperEl(el); this._uiEl = this._buildUIElement(); this._wrapperEl.appendChild(this._uiEl); this._bindUIElementEvents(); if (selectedOption) { this._setUISelectedValue(selectedOption.value); } if (this.getFormElement().disabled) { this.disable(); } } /** * Wraps the passed element inside of a custom container element. * @param {HTMLElement} el - The element to be wrapped inside of the container * @returns {Element} Returns the container element that contains the passed el * @private */ _buildWrapperEl (el) { let parent = el.parentNode; var outerEl = document.createElement('div'); outerEl.classList.add(this.options.customWrapperClass); parent.replaceChild(outerEl, el); outerEl.appendChild(el); return outerEl; } /** * Builds the UI element. * @returns {Element} * @private */ _buildUIElement () { var options = this.options, formEl = options.el, uiEl = document.createElement('div'); this._origTabIndex = formEl.tabIndex; uiEl.classList.add(this.options.containerClass); uiEl.innerHTML = this._buildSelectedValueHtml() + this._buildOptionsHtml(); // only switch tab index to ui element when not on a mobile device // since we're using native there if (!DeviceManager.isMobile()) { uiEl.tabIndex = this._origTabIndex || 0; // remove form element from being focused since we now have the UI element formEl.tabIndex = -1; } return uiEl; } /** * Sets the UI representation of the select dropdown to a new value. * @param {string} dataValue - The new data value * @private * @memberOf Dropdown */ _setUISelectedValue (dataValue) { var optionsContainerEl = this.getUIElement().getElementsByClassName(this.options.optionsContainerClass)[0], prevSelectedOption = optionsContainerEl.getElementsByClassName(this.options.optionsSelectedClass)[0], newSelectedOptionEl = optionsContainerEl.querySelectorAll('.' + this.options.optionsClass + '[data-value="' + dataValue + '"]')[0], selectedClass = this.options.optionsSelectedClass, selectedValueContainerEl = this.getUIElement().getElementsByClassName(this.options.selectedValueContainerClass)[0], displayValue = newSelectedOptionEl ? newSelectedOptionEl.textContent : ''; selectedValueContainerEl.setAttribute('data-value', dataValue); selectedValueContainerEl.innerHTML = displayValue; // remove selected class from previously selected option if (prevSelectedOption) { prevSelectedOption.classList.remove(selectedClass) } // add selected class to new option if (newSelectedOptionEl) { newSelectedOptionEl.classList.add(selectedClass); } } /** * When a key press event is registered when focused on the UI Element. * @param {KeyboardEvent} e - The key up event */ onKeyStrokeUIElement (e) { var options = this.options, highlightClass = this.options.optionsHighlightedClass, uiEl = this.getUIElement(), uiContainer = uiEl.getElementsByClassName(options.optionsContainerClass)[0], selectedUIOptionEl = uiContainer.getElementsByClassName(options.optionsSelectedClass)[0], highlightedOptionEl = uiContainer.getElementsByClassName(highlightClass)[0] || selectedUIOptionEl, key = this._keyMap[e.keyCode]; if (!key) { return false; } else if ((key === 'up' || key === 'down') && !this.isOptionsContainerActive()) { this.showOptionsContainer(); } else if (key === 'up') { this._onKeyStrokeUp(highlightedOptionEl); } else if (key === 'down') { this._onKeyStrokeDown(highlightedOptionEl); } else if (!this.isOptionsContainerActive()) { return false; } else if (key === 'esc') { this.hideOptionsContainer(); } else if (key === 'space') { this.setValue(highlightedOptionEl.dataset.value); this.hideOptionsContainer(); } } /** * When the up arrow is triggered. * @param {HTMLElement} highlightedOptionEl - The currently highlighted UI option element * @private */ _onKeyStrokeUp (highlightedOptionEl) { var highlightClass = this.options.optionsHighlightedClass, prevSibling = highlightedOptionEl.previousSibling; highlightedOptionEl.classList.remove(highlightClass); // go to bottom option if at the beginning if (!prevSibling) { prevSibling = this.getUIElement().getElementsByClassName(this.options.optionsContainerClass)[0].lastChild; } prevSibling.classList.add(highlightClass); } /** * When the down arrow is triggered. * @param {HTMLElement} highlightedOptionEl - The currently highlighted UI option element * @private */ _onKeyStrokeDown (highlightedOptionEl) { var highlightClass = this.options.optionsHighlightedClass, nextSibling = highlightedOptionEl.nextSibling; highlightedOptionEl.classList.remove(highlightClass); if (!nextSibling) { // get top option element if at end nextSibling = this.getUIElement().getElementsByClassName(this.options.optionsContainerClass)[0].firstChild; } nextSibling.classList.add(highlightClass); } /** * When the select element is focused. * @private * @param e */ _onFocusFormElement (e) { if (this.options.onFocus) { this.options.onFocus(e); } } /** * When the select element loses focused. * @private * @param e */ _onBlurFormElement (e) { if (this.options.onBlur) { this.options.onBlur(e); } } /** * When the UI Element is in focus. * @private * @param e */ _onFocusUIElement (e) { if (!DeviceManager.isMobile()) { // prevent default window actions on key strokes this.addEventListener(window, 'keydown', '_onWindowKeyup', this, false); this.addEventListener(window, 'keyup', '_onWindowKeyup', this, false); // add key stroke event listeners this.addEventListener(this.getUIElement(), 'keyup', 'onKeyStrokeUIElement', this); } if (this.options.onFocus) { this.options.onFocus(e); } } /** * When the user taps a keyboard key. * @param {Event} e - The event object * @private */ _onWindowKeyup (e) { // if any keys we're listening to internally, prevent default window behavior if (this._keyMap[e.keyCode]) { e.preventDefault(); } } /** * When the UI Element loses focus * @private * @param e */ _onBlurUIElement (e) { if (!DeviceManager.isMobile()) { this.removeEventListener(this.getUIElement(), 'keyup', 'onKeyStrokeUIElement', this); this.removeEventListener(window, 'keydown', '_onWindowKeyup', this, false); this.removeEventListener(window, 'keyup', '_onWindowKeyup', this, false); } if (this.options.onBlur) { this.options.onBlur(e); } } /** * When an option element inside of the UI element is hovered over * @param {MouseEvent} e - The mouse event * @private */ _onMouseEnterUIElement (e) { e.currentTarget.classList.add(this.options.optionsHighlightedClass); } /** * When hovering over an option element inside of the UI element stops. * @param {MouseEvent} e - The mouse event * @private */ _onMouseLeaveUIElement (e) { e.currentTarget.classList.remove(this.options.optionsHighlightedClass); } /** * Sets up click events on the ui element and its children. * @private * @memberOf Dropdown */ _bindUIElementEvents () { var uiEl = this.getUIElement(), uiValueContainer = uiEl.getElementsByClassName(this.options.selectedValueContainerClass)[0], formEl = this.getFormElement(); this.addEventListener(uiEl, 'focus', '_onFocusUIElement', this); this.addEventListener(uiEl, 'blur', '_onBlurUIElement', this); this.addEventListener(formEl, 'focus', '_onFocusFormElement', this); this.addEventListener(formEl, 'blur', '_onBlurFormElement', this); // add click events on container this.addEventListener(uiValueContainer, 'click', '_onClickUIValueContainer', this); } /** * Removes all ui element event listeners. * @private */ _unbindUIElementEvents () { var uiEl = this.getUIElement(), uiValueContainer = uiEl.getElementsByClassName(this.options.selectedValueContainerClass)[0], formEl = this.getFormElement(); this.removeEventListener(uiEl, 'focus', '_onFocusUIElement', this); this.removeEventListener(uiEl, 'blur', '_onBlurUIElement', this); this.removeEventListener(formEl, 'focus', '_onFocusFormElement', this); this.removeEventListener(formEl, 'blur', '_onBlurFormElement', this); // add click events on container this.removeEventListener(uiValueContainer, 'click', '_onClickUIValueContainer', this); } /** * Adds click events on all option elements of the UI-version of dropdown. */ bindUIOptionEvents () { var optionEls = this.getUIElement().getElementsByClassName(this.options.optionsClass), i, count = optionEls.length; for (i = 0; i < count; i++) { let el = optionEls[i]; this.addEventListener(el, 'click', '_onClickUIOption', this); this.addEventListener(el, 'mouseenter', '_onMouseEnterUIElement', this); this.addEventListener(el, 'mouseleave', '_onMouseLeaveUIElement', this); } } /** * Removes click events from all options elements of the UI-version of dropdown. */ unbindUIOptionEvents () { var optionEls = this.getUIElement().getElementsByClassName(this.options.optionsClass), i, count = optionEls.length; for (i = 0; i < count; i++) { let el = optionEls[i]; this.removeEventListener(el, 'click', '_onClickUIOption', this); this.removeEventListener(el, 'mouseenter', '_onMouseEnterUIElement', this); this.removeEventListener(el, 'mouseleave', '_onMouseLeaveUIElement', this); } } /** * When clicking on the div that represents the select value. * @private * @memberOf Dropdown */ _onClickUIValueContainer () { if (this.getFormElement().disabled) { return false; } else if (this.isOptionsContainerActive()) { this.hideOptionsContainer(); } else { this.showOptionsContainer(); } } /** * Shows the UI options container element. */ showOptionsContainer () { var uiEl = this.getUIElement(), options = this.options, selectedUIOption = this.getUIOptionByDataValue(this.getValue()) || uiEl.getElementsByClassName(options.optionsClass)[0]; uiEl.classList.add(options.optionsContainerActiveClass); this.bindUIOptionEvents(); // set selected class on selected value for instances where it is not present // like upon showing the container for the first time if (selectedUIOption) { selectedUIOption.classList.add(this.options.optionsSelectedClass); } this.addEventListener(document.body, 'click', 'onClickDocument', this); } /** * Hides the UI options container element. */ hideOptionsContainer () { // Redraw of options container needed for iPad and Safari. if (DeviceManager.isBrowser('safari')) { this.redrawOptionsContainer(); } this.getUIElement().classList.remove(this.options.optionsContainerActiveClass); this.unbindUIOptionEvents(); this.removeEventListener(document.body, 'click', 'onClickDocument', this); } /** * Forces a redraw of the options container element. * @note If dropdown options are hidden on default, * this will force the styles to be updated when active class is removed. */ redrawOptionsContainer () { var optionsContainerEl = this.getUIElement().getElementsByClassName(this.options.optionsContainerClass)[0], currentOverflowAttr = optionsContainerEl.style.overflow; // update overflow property to force the redraw. optionsContainerEl.style.overflow = 'hidden'; optionsContainerEl.offsetHeight; // if there was an original overflow property, reset it // or remove the property if (currentOverflowAttr) { optionsContainerEl.style.overflow = currentOverflowAttr; } else { optionsContainerEl.style.removeProperty('overflow'); } } /** * Whether the UI options container element is open. * @returns {boolean} Returns true if container is open */ isOptionsContainerActive () { return this.getUIElement().classList.contains(this.options.optionsContainerActiveClass); } /** * When document is clicked. * @param {Event} e */ onClickDocument (e) { var closestUIContainer = this.getClosestAncestorElementByClassName(e.target, this.options.containerClass); if (!closestUIContainer || closestUIContainer !== this.getUIElement()) { // clicked outside of ui element! this.hideOptionsContainer(); } } /** * When one of the ui divs (representing the options elements) is clicked. * @param {Event} e * @private * @memberOf Dropdown */ _onClickUIOption (e) { var selectedOption = e.currentTarget, newDataValue = selectedOption.dataset.value; if (this.getValue() !== newDataValue) { // set the current value of the REAL dropdown this.setValue(newDataValue); // set value of ui dropdown this._setUISelectedValue(newDataValue); } this.hideOptionsContainer(); } /** * Builds the html for the dropdown value. * @returns {string} * @private * @memberOf Dropdown */ _buildSelectedValueHtml () { return '<div class="' + this.options.selectedValueContainerClass + '" data-value=""></div>'; } /** * Builds a representative version of the option elements of the original select. * @returns {string} Returns the html of the options container along with its nested children * @private * @memberOf Dropdown */ _buildOptionsHtml () { var options = this.options, uiOptionsContainer = document.createElement('div'), html = '<div class="' + options.optionsContainerClass + '">', optionEls = options.el.getElementsByTagName('option'), count = optionEls.length, i, option, selectedClass = ''; uiOptionsContainer.classList.add(options.optionsContainerClass); for (i = 0; i < count; i++) { option = optionEls[i]; selectedClass = option.hasAttribute('selected') ? options.optionsSelectedClass : ''; html += '<div class="' + options.optionsClass + ' ' + selectedClass + '" data-value="' + option.value + '">' + option.textContent + '</div>'; } html += '</div>'; // close container tag return html; } /** * When the select value changes. * @param e * @private * @memberOf Dropdown */ _onSelectChange (e) { var value = this.getValue(); this._setUISelectedValue(value); if (this.options.onChange) { this.options.onChange(value, this.getFormElement(), this.getUIElement(), e); } } /** * Returns the element that represents the div-version of the dropdown. * @returns {HTMLElement|*} */ getUIElement () { return this._uiEl; } /** * Gets an option element by its value attribute. * @param {string} dataValue - The value attribute of the option desired * @returns {*} * @memberOf Dropdown */ getOptionByDataValue (dataValue) { return this.options.el.querySelectorAll('option[value="' + dataValue + '"]')[0]; } /** * Gets an UI option element by its data value. * @param dataValue * @returns {*} */ getUIOptionByDataValue (dataValue) { return this.getUIElement().querySelectorAll('.' + this.options.optionsClass + '[data-value="' + dataValue + '"]')[0]; } /** * Gets an option element by its text content. * @param {string} displayValue - The text content that the eleemnt should have in order to be returned * @returns {*|HTMLOptionElement} * @memberOf Dropdown */ getOptionByDisplayValue (displayValue) { var optionEls = this.options.el.querySelectorAll('option'), i, count = optionEls.length, option; for (i = 0; i < count; i++) { option = optionEls[i]; if (option.textContent === displayValue) { break; } } return option; } /** * Sets the dropdown to a specified value (if there is an option * element with a value attribute that contains the value supplied) * @param {string} dataValue - The value to set the dropdown menu to * @memberOf Dropdown */ setValue (dataValue) { var origOptionEl = this.getOptionByDataValue(this.getValue()), newOptionEl = this.getOptionByDataValue(dataValue), e = document.createEvent('HTMLEvents'), formEl = this.getFormElement(); e.initEvent('change', false, true); // switch selected value because browser doesnt do it for us if (origOptionEl) { origOptionEl.removeAttribute('selected'); } if (newOptionEl) { newOptionEl.setAttribute('selected', 'selected'); // in most cases, setting attribute (above) also updates the dropdown's value // but for some browsers (like phantomjs), we need to manually set it formEl.value = dataValue; // trigger change event on dropdown formEl.dispatchEvent(e); } else { console.warn('Form Dropdown Error: Cannot call setValue(), dropdown has no option element with a ' + 'value attribute of ' + dataValue + '.'); } this._setUISelectedValue(dataValue); } /** * Updates markup to show new dropdown option values. * @param {Array} optionsData - An array of objects that maps the new data values to display values desired * @param {Object} [options] - Update options * @param {Boolean} [options.replace] - If true, the new options will replace all current options, if false, new options will be merged with current ones */ updateOptions (optionsData, options) { var uiOptionsContainer = this.getUIElement().getElementsByClassName(this.options.optionsContainerClass)[0], frag = document.createDocumentFragment(), optionEl; options = options || {}; if (options.replace) { this.clearOptions(); } this._updateFormOptionElements(optionsData); optionsData.forEach(function (obj) { optionEl = document.createElement('div'); optionEl.setAttribute('data-value', obj.dataValue); optionEl.classList.add(this.options.optionsClass); optionEl.innerHTML = obj.displayValue; frag.appendChild(optionEl); }.bind(this)); uiOptionsContainer.appendChild(frag); } /** * Clears all options in the dropdown. */ clearOptions () { var uiOptionsContainer = this.getUIElement().getElementsByClassName(this.options.optionsContainerClass)[0], formEl = this.getFormElement(); formEl.innerHTML = ''; uiOptionsContainer.innerHTML = ''; } /** * Updates markup to show new form elements. * @param {Array} optionsData - An array of objects that maps the new data values to display values desired * @param {boolean} reset - Whether to replace current options, or merge with them * @private */ _updateFormOptionElements (optionsData, reset) { var formEl = this.getFormElement(), frag = document.createDocumentFragment(), optionEl; optionsData.forEach(function (obj) { optionEl = document.createElement('option'); optionEl.setAttribute('value', obj.dataValue); optionEl.innerHTML = obj.displayValue; frag.appendChild(optionEl); }); if (reset) { formEl.innerHTML = ''; } else { } formEl.appendChild(frag); } /** * Disables the dropdown. */ disable () { this.getUIElement().classList.add(this.options.disabledClass); this.getFormElement().disabled = true; } /** * Enables the dropdown. */ enable () { this.getUIElement().classList.remove(this.options.disabledClass); this.getFormElement().disabled = false; } /** * Clears all options in the dropdown */ clear () { var optionEl = this.getOptionByDataValue(''); if (optionEl) { this.setValue(''); } } /** * Returns the text inside the option element that is currently selected. * @returns {*} * @memberOf Dropdown */ getDisplayValue () { return this.getOptionByDataValue(this.getValue()).textContent; } /** * Destruction of this class. * @memberOf Dropdown */ destroy () { var el = this.options.el; this.unbindUIOptionEvents(); this._unbindUIElementEvents(); this.removeEventListener(el, 'change', '_onSelectChange', this); el.style.display = this._origDisplayValue; // put original display back el.tabIndex = this._origTabIndex; // restore html this._wrapperEl.parentNode.replaceChild(el, this._wrapperEl); super.destroy(); } } module.exports = Dropdown;