Skip to main content

This script aims to support the new HTML5 input types and most of the new standard HTML5 form attributes.

/*!
 * HTML forms enhancer
 *
 * This script aims to support the new HTML5 input types and most of the new
 * standard HTML5 form attributes.
 *
 * usage:
 *
 *   window.onload = function() { nwxforms(this); }
 *
 * Github: https://github.com/dperini/nwxforms
 * Example: http://dl.dropboxusercontent.com/u/598365/html5forms/example1.html
 *
 * Copyright (c) 2010-2013 Diego Perini <http://www.iport.it>
 *
 * @param  {Object} global required browsing context
 */

function nwxforms(global) {

  var cache = {},

    doc = global.document,

    // helper strings for event shortcut
    w3c = !! doc.addEventListener,
    add = w3c ? 'addEventListener' : 'attachEvent',
    rem = w3c ? 'removeEventListener' : 'detachEvent',
    blur = w3c ? 'blur' : 'focusout',
    focus = w3c ? 'focus' : 'focusin',
    input = w3c ? 'input' : 'propertychange',
    target = w3c ? 'target' : 'srcElement',
    prefix = w3c ? '' : 'on',

    // cache for test elements references
    tagStore = {},

    // handled protocols RE string
    protoRE = '(?:(?:ftp|http|https)://)',

    // host.domain.tld RE string, for stricter checking this
    // could be used: https://gist.github.com/dperini/729294
    hostRE = '(?:[a-zA-Z0-9][-a-zA-Z0-9]{0,61}[a-zA-Z0-9]\\.)+[a-zA-Z]{2,6}',

    // mail address RE string, RFC2822/RFC5322 no double quotes, no UTF8 (RFC5336)
    mailRE =
      '(?:[\\w!#$%&\x27*+/=?^`{|}~-]+)(?:\\.[\\w!#$%&\x27*+/=?^`{|}~-]+)*',

    // date RE string, yyyy-mm-dd (US) dd-mm-yyyy (EU/IT)
    // multiple separators: "-", ".", "/", " " or none
    dateRE =
    // [yy]yy-mm-dd
    '(?:\\d{2}|\\d{4})[-.\\/ ]\\d{1,2}[-.\\/ ]\\d{1,2}|' +
    // dd-mm-[yy]yy
    '\\d{1,2}[-.\\/ ]\\d{1,2}[-.\\/ ](?:\\d{2}|\\d{4})|' +
    // [yy]yymmdd
    '(?:\\d{2}|\\d{4})\\d{2}\\d{2}|' +
    // ddmm[yy]yy
    '\\d{2}\\d{2}(?:\\d{2}|\\d{4})',

    // month RE string, yyyy-mm (US) mm-yyyy (EU/IT)
    // multiple separators: "-", ".", "/", " " or none
    monthRE =
    // [yy]yy mm
    '(?:\\d{2}|\\d{4})[-.\\/ ]\\d{1,2}|' +
    // mm [yy]yy
    '\\d{1,2}[-.\\/ ](?:\\d{2}|\\d{4})|' +
    // [yy]yymm
    '(?:\\d{2}|\\d{4})\\d{2}|' +
    // mm[yy]yy
    '\\d{2}(?:\\d{2}|\\d{4})',

    // week RE string, yyyy-Www (US) Www-yyyy (EU/IT)
    // a "W" marker is used to differentiate from month format
    // http://www.w3.org/TR/html-markup/input.week.html#form.data.week_xref1
    weekRE = '\\d{4}-W\\d{2}|W\\d{2}-\\d{4}',

    // time RE string, format: hh:mm:ss.ff
    // RFC3339 partial-time format no TimeZone
    timeRE = '\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{2})?',

    // numeric RE string, format: -.01e2
    numberRE = '(?:\\+|-)?(?:\\d+)(?:.\\d+)?(?:e-?\\d+)?',

    // color RE string, format: #hhh, #hhhhhh, name
    colorRE = '#[0-9a-fA-F]{3,3}|#[0-9a-fA-F]{6,6}|[a-zA-Z]+-?[a-zA-Z]+',

    // range RE string
    rangeRE = numberRE,

    // search RE string
    searchRE = '[^\\r\\n\\f]{0,}',

    // phone RE string, +int_prefix (area_code) phone_number
    telRE =
      '(?:\\+?(?:\\d{1,3}[-. ]?))?(?:\\(?\\d{2,4}\\)?[-. ]?)?\\d{3}[-. ]?\\d{4}',

    TYPES_RE = {
      // HTML4 standard types
      // no special handling
      // 'password': '.*',
      // 'checkbox': '.*',
      // 'hidden': '.*',
      // 'button': '.*',
      // 'submit': '.*',
      // 'reset': '.*',
      // 'image': '.*',
      // 'radio': '.*',
      // 'file': '.*',
      // 'text': '.*',
      // HTML5 extended types
      'number': numberRE,
      'search': searchRE,
      'color': colorRE,
      'range': rangeRE,
      'month': monthRE,
      'date': dateRE,
      'time': timeRE,
      'week': weekRE,
      'datetime-local': dateRE + ' ' + timeRE,
      'datetime': dateRE + ' ' + timeRE,
      'email': mailRE + '@' + hostRE,
      'url': protoRE + '?' + hostRE,
      'tel': telRE
    },

    // check native support for attribute
    // on specific element types (tagName)
    // don't recreate elements, cache them
    supportAttribute = function (element, attribute) {
      var tagName = element.nodeName;
      return tagName in tagStore ?
        attribute in tagStore[tagName] :
        attribute in (tagStore[tagName] = doc.createElement(tagName));
    },

    // don't copy these attributes
    skipAttr = {
      type: 1,
      value: 1,
      maxlength: 1
    },

    // collection of page forms and elements
    elements = {
      'form': doc.getElementsByTagName('form'),
      'input': doc.getElementsByTagName('input'),
      'select': doc.getElementsByTagName('select'),
      'textarea': doc.getElementsByTagName('textarea')
    },

    // element may be hidden or not displayed
    // trying to focus()/select() throw error
    focusable = function (element) {
      return element && element.offsetWidth > 0 && element.offsetHeight > 0 &&
        element.style.display != 'none' && element.style.visibility != 'hidden';
    },

    // give keyboard focus to element
    setfocus = function (element) {
      if (!focusable(element)) return;
      try {
        // IE needs to defer calling .focus() to work
        setTimeout(function () {
          element.focus();
        }, 0);
      }
      catch (e) {}
    },

    // IE: prevent ESC+ESC (reset)
    // erasing entered user data
    reset = function (event) {
      event[target].value = '';
      event[target].focus();
      return stop(event);
    },

    // prevent event default action
    stop = function (event) {
      if (event.preventDefault) event.preventDefault();
      else event.returnValue = false;
      return false;
    },

    // copy element attributes on clone element
    cloneAttributes = function (element, clone) {
      var i, j, a = element.attributes,
        l = a.length,
        n;
      for (i = 0; l > i; ++i) {
        j = a[i].name.toLowerCase();
        n = element.attributes[j];
        if (n && n.specified && !skipAttr[j]) {
          if (j == 'class') {
            clone.setAttribute('className', n.value);
          }
          clone.setAttribute(a[i].name, n.value);
        }
      }
      return clone;
    },

    // change field type from password to text
    switchType = w3c ? function (element) {
      element.type = 'text';
      element.value = element.getAttribute('placeholder');
      element.extype = 'password';
      addClass(element, 'placeholder');
    } : function (element) {
      if (!cache[element.uniqueID]) {
        cache[element.uniqueID] = cloneAttributes(element, doc.createElement(
          'input'));
        cache[element.uniqueID][add](prefix + focus, passwordFocus, 0);
        cache[element.uniqueID][add](prefix + blur, passwordBlur, 0);
      }
      cache[element.uniqueID].value = element.getAttribute('placeholder');
      addClass(cache[element.uniqueID], 'placeholder');
      element.replaceNode(cache[element.uniqueID]);
      cache[element.uniqueID].extype = element;
    },

    // field types: select
    changeHandler = function (event) {
      if (event.type == 'change') {
        (event[target].value !== '' || (event[target].selectedIndex > 0 &&
            event[target].options[event[target].selectedIndex].text !== '') ?
          removeClass : addClass)(event[target], 'placeholder');
      }
      else {
        (event.type == focus ? addClass : removeClass)(event[target], 'focused');
      }
    },

    // field types: password
    passwordBlur = function (event) {
      if (event[target].value === '') {
        if (w3c) {
          addClass(event[target], 'placeholder');
          if (event[target].value === '') {
            event[target].type = 'text';
            event[target].value = event[target].getAttribute('placeholder');
          }
        }
        else {
          event[target].replaceNode(cache[event[target].uniqueID]);
          removeClass(cache[event[target].uniqueID], 'focused');
        }
      }
      removeClass(event[target], 'focused');
    },

    // field types: password
    passwordFocus = function (event) {
      if (w3c) {
        if (event[target].value == event[target].getAttribute('placeholder')) {
          event[target].value = '';
          event[target].type = event[target].extype;
          if (document.activeElement !== event[target]) {
            event[target].focus();
          }
        }
        removeClass(event[target], 'placeholder');
        addClass(event[target], 'focused');
      }
      else {
        if (event[target].extype) {
          addClass(event[target].extype, 'focused');
          event[target].extype[rem](prefix + focus, passwordFocus, 0);
          event[target].replaceNode(event[target].extype);
          setfocus(event[target].extype);
          event[target].extype[add](prefix + focus, passwordFocus, 0);
        }
        else {
          addClass(event[target], 'focused');
        }
      }
    },

    // field types: text, textarea
    blurHandler = function (event) {
      if (event[target].value === '') {
        addClass(event[target], 'placeholder');
        if (!supportAttribute(event[target], 'placeholder')) {
          event[target].value = event[target].getAttribute('placeholder');
        }
      }
      removeClass(event[target], 'focused');
    },

    // field types: text, textarea
    focusHandler = function (event) {
      if (event[target].value == event[target].getAttribute('placeholder')) {
        if (!supportAttribute(event[target], 'placeholder')) {
          event[target].value = '';
        }
      }
      if (event[target].value === '') {
        removeClass(event[target], 'placeholder');
      }
      addClass(event[target], 'focused');
    },

    // control types: textarea
    maxlengthHandler = function (event) {
      if (event[target].value != event[target].getAttribute('placeholder')) {
        if (event[target].value.length > event[target].getAttribute('maxlength')) {
          event[target].value = event[target].value.substring(0, event[target].getAttribute(
            'maxlength'));
        }
      }
    },

    // control types: text
    patternHandler = function (event) {
      // avoid loops on IE since changing class later will fire this again
      // we only need to be notified for the 'value' property to simulate
      // W3C textInput event (input or textInput in other browsers)
      if ('propertyName' in event && event.propertyName != 'value') return true;

      var pattern;

      if ((pattern = event[target].getAttribute('pattern'))) {
        try {
          if (!RegExp(pattern).test(event[target].value)) {
            addClass(event[target], 'mismatch');
          }
          else {
            removeClass(event[target], 'mismatch');
          }
        }
        catch (err) {}
      }
      return stop(event);
    },

    // control types: text
    regexpHandler = function (event) {
      var key = String.fromCharCode(event.keyCode ? event.keyCode : event.which),
        special = {
          '\x08': 1,
          '\x09': 1,
          '\x0d': 1,
          '\x1b': 1
        };

      // does key match supplied keylist ?
      if (key.match(event[target].getAttribute('data-regexp'))) {
        return true;
      }

      // allow special keys
      if (key in special || event.charCode === 0 || event.altKey || event.ctrlKey ||
        event.metaKey) {
        return true;
      }
      return stop(event);
    },

    // check html attribute was specified on element
    requireHelper = function (element, attribute) {
      var node = element.attributes[attribute];
      return node && node.value !== null;
    },

    hasClass = function (element, className) {
      return RegExp(("(^|\\s)" + className + "(\\s|$)")).test(element.className);
    },

    addClass = function (element, className) {
      if (!hasClass(element, className)) {
        element.className = element.className.length ? (element.className + ' ' +
          className) : className;
      }
      return element;
    },

    removeClass = function (element, className) {
      if (hasClass(element, className)) {
        element.className = element.className.replace(RegExp('(?:^|\\s)' +
          className + '(\\s|$)'), '$1');
      }
      return element;
    };

  (function toggle(event) {

    var i, j, k, autofocus,
      element, field, invalid,
      method, name, node, pattern, replace;

    // handle the submit event invocation
    // and aborts in case validation fail
    if (event && event.type == 'submit') {
      invalid = false;
      for (j = 0; event[target].elements.length > j; ++j) {
        element = event[target].elements[j];
        // needed for ENTER key submits to avoid bfcache
        // remembering the focused status of the element
        removeClass(element, 'focused');
        if (!element.name || element.type == 'hidden' || element.disabled ||
          element.readOnly) {
          continue;
        }
        // test attributes collection instead of getAttribute to avoid
        // false positives on Opera 7.50, IE6 and other older browsers
        // only perform validation if the control value is not empty (@mathias, @miketaylr)
        if (element.value !== '' && element.getAttribute('placeholder') !==
          element.value) {
          if ((node = element.attributes['pattern']) && (pattern = node.value)) {
            try {
              if (RegExp(pattern).test(element.value) && element.getAttribute(
                'placeholder') != element.value) {
                continue;
              }
            }
            catch (err) {}
            invalid = true;
            break;
          }
        }
        if (element.attributes['required'] && (element.value === '' ||
          element.getAttribute('placeholder') == element.value)) {
          if ('selectedIndex' in element && (k = element.selectedIndex) > -1) {
            // expectation here is for a value or at least index > 0 and valid text
            if (element.value !== '' || (k > 0 && element.options[k].text !==
              '')) {
              continue;
            }
          }
          invalid = true;
          break;
        }
      }
      if (invalid) {
        addClass(element, 'focused');
        setfocus(element);
        stop(event);
        return;
      }
    }

    // handle both initial setup and the
    // succesful submit event invocation
    method = event === true ? add : rem;

    for (i in elements) {
      for (j = 0; elements[i].length > j; ++j) {
        element = elements[i][j];
        name = element.nodeName.toLowerCase();

        if (name == 'form') {
          if (event === true || event.type != 'submit') {
            element[method](prefix + 'reset', reset, false);
            element[method](prefix + 'submit', toggle, false);
          }
        }

        if (name == 'input') {
          if (!element.getAttribute('data-regexp')) {
            switch (element.getAttribute('type')) {
            case 'color':
              element.setAttribute('data-regexp', '[-\w#]');
              break;
            case 'number':
              element.setAttribute('data-regexp', '[-+.0-9e]');
              break;
            case 'week':
              element.setAttribute('data-regexp', '[-.\\/ 0-9W]');
              break;
            case 'date':
            case 'time':
            case 'month':
            case 'datetime':
            case 'datetime-local':
              element.setAttribute('data-regexp', '[-.\\/ 0-9]');
              break;
            default:
              break;
            }
          }

          if (!element.getAttribute('pattern')) {
            if ((type = element.getAttribute('type')) && TYPES_RE[type]) {
              addClass(element, type);
              element.setAttribute('pattern', '^(?:' + TYPES_RE[type] + ')$');
            }
          }
        }

        if (name == 'input' || name == 'textarea') {
          // required attribute
          if (requireHelper(element, 'required') && event === true) {
            addClass(element, 'required');
          }

          // placeholder attribute
          if (requireHelper(element, 'placeholder')) {

            if (event === true || event.type != 'submit') {

              replace = false;
              if (!supportAttribute(element, 'placeholder')) {
                if (element.type == 'password') {
                  element[method](prefix + blur, passwordBlur, false);
                  element[method](prefix + focus, passwordFocus, false);
                  switchType(element);
                  replace = true;
                }
                else {
                  if (element.value === '') {
                    element.value = element.getAttribute('placeholder');
                  }
                  replace = false;
                }
              }
              if (replace === false) {
                element[method](prefix + blur, blurHandler, false);
                element[method](prefix + focus, focusHandler, false);
                if (element.value == element.getAttribute('placeholder')) {
                  addClass(element, 'placeholder');
                }
              }

            }

            if (event === true) {
              // needed for bfcache support, VERIFY !
              element.setAttribute('autocomplete', 'off');
            }
            else {
              // needed for bfcache support, VERIFY !
              element.setAttribute('autocomplete', 'on');
              // clear value before submit if it contains the placeholder
              if (element.value == element.getAttribute('placeholder')) {
                element.value = '';
              }
            }

          }

          // autofocus attribute
          // code need to take over Opera own "autofocus"
          // as a temporary fix due to different behavior
          if (requireHelper(element, 'autofocus') ||
            element.attributes['autofocus']) {
            autofocus = element;
          }

          // maxlength attribute
          if (requireHelper(element, 'maxlength')) {
            element[method](prefix + input, maxlengthHandler, false);
          }

          // pattern attribute
          if (requireHelper(element, 'pattern')) {
            element[method](prefix + input, patternHandler, false);
          }

          // data-regexp attribute (as seen in ExtJS)
          // limit the range of keys available in this field
          // non standard attribue but I both need & like this
          if (requireHelper(element, 'data-regexp')) {
            element[method](prefix + 'keypress', regexpHandler, false);
          }

        }

        if (name == 'select') {

          element[method](prefix + blur, changeHandler, false);
          element[method](prefix + focus, changeHandler, false);
          element[method](prefix + 'change', changeHandler, false);

          if (event === true) {
            k = 0;
            while (element.options[k]) {
              node = element.options[k].attributes['value'];
              if (node && node.value === '') {
                addClass(element.options[k], 'placeholder');
              }
              k++;
            }
            if (element.value === '') {
              addClass(element, 'placeholder');
            }
            if (requireHelper(element, 'required')) {
              addClass(element, 'required');
            }
          }
        }

      }
    }

    // give keyboard focus to the last
    // "autofocus" element in document
    if (event === true && focusable(autofocus)) {
      autofocus.blur();
      setfocus(autofocus);
    }

    // before page unloads call toggle() to cleanup events
    if (global[method]) {
      global[method](prefix + 'beforeunload', toggle, false);
    }
    else {
      // fix for older Opera < 8
      global.document[method](prefix + 'beforeunload', toggle, false);
    }

    return;

  })(true);

}