Manual Reference Source Test

src/elements/inputelement.js

/**
*  @file Input Element definition class
*  @author  Liqueur de Toile <contact@liqueurdetoile.com>
*  @license Apache-2.0 {@link https://www.apache.org/licenses/LICENSE-2.0}
*/

import ObjectArray from 'dot-object-array';
import HtmlElement from 'elements/htmlelement';
import Q from 'query';
import uniqid from 'utilities/uniqid';

/**
*  InputElement is the common basic class wrapping all vanilla HTML inputs.
*  It provides all properties and method to :
*  - get/set Element value
*  - reset Element value
*  - validate Element value
*
*  @version 1.0.0
*  @since 1.0.0
*  @author Liqueur de Toile <contact@liqueurdetoile.com>
*/

export default class InputElement extends HtmlElement {
  /**
  *  InputElement constructor
  *  Node and options are passed through {@link Element} call.
  *
  *  If the Element have required or pattern atributes, validation
  *  rules will be automatically set accordingly.
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *  @private
  *
  *  @param {Element} node Input element
  *  @param {KeyValueObject} options Options for the InputElement
  *
  *  Specific keys are :
  *
  *  - `value`: initial value of the Element
  *  - `options`: list of options {value: text} for a select tag
  *
  *  @returns {InputElement}
  *  @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement
  */
  constructor(node, options = {}) {
    super(node, options);

    /**
    *  Rules for validation
    *
    *  @type {ObjectArray}
    *  @since 1.0.0
    */
    this._rules = new ObjectArray();

    /**
    *  Errors after validation
    *
    *  @type {ObjectArray}
    *  @since 1.0.0
    */
    this._errors = new ObjectArray();

    // Add input type to Textarea and Select for further switch operations
    if (this.node.nodeName === 'TEXTAREA') this.type = 'textarea';
    if (this.node.nodeName === 'SELECT') {
      this.type = 'select';
      // Generates options items
      if (options.options) {
        for (let key in options.options) {
          this.append(`<option value="${key}" ${key === options.value ? 'selected' : ''}>` +
                      `${options.options[key]}` +
                      '</option>');
        }
      }
    }

    // Set value
    if (options.value !== undefined) {
      this.value = this.node.defaultValue = options.value;
    }

    // Set validation rules
    if (this.attr('required')) this.rule('required');
    if (this.attr('pattern')) this.rule('patternAttribute', this.attr('pattern'), null, true);
    this.rule(this.attr('type'));
  }

  /**
  *  Get a unique name
  *  - `name`attribute value
  *  - `id` attribute value if name not set
  *  - A random unique id if both name and value are not set
  *
  *  @type {string}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get name() {
    let name = this.node.name || this.node.id;

    if (name === '') name = 'input-' + uniqid();
    return name;
  }

  /**
  *  Get standardized lowercased type attribute
  *
  *  @type {string}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get type() {
    return (this.attr('type') || 'text').toLowerCase();
  }

  /**
  *  Set input type attribute
  *
  *  @type {string}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  set type(t) {
    this.attr('type', t);
  }

  /**
  *  Get value
  *
  *  It normalizes the behaviour for boolean inputs (radio & checkbox) :
  *  - If no value attribute is set, it will return `true` or `false`
  *  - If a value attribute is set, it will return undefined and will be
  *  skipped in fields list if not checked or returns its value otherwise.
  *
  *  @type {boolean|string|undefined}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *  @todo
  *  - Add more casting (all values are considered as string when extracted)
  */
  get value() {
    var ret;

    switch (this.type) {
      case 'checkbox' :
      case 'radio' :
        if (typeof this.attr('value') !== 'undefined' && this.attr('value') !== null) {
          ret = (this.node.checked ? this.attr('value') : undefined);
        } else ret = this.node.checked ? 1 : 0;
        break;
      default:
        ret = (this.node.value || '').trim();
        break;
    }
    return ret;
  }

  /**
  *  Set value
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  set value(val) {
    val = (String(val)).trim();

    switch (this.type) {
      case 'textarea': // Update textarea content
        this.element.value = val;
        this.html(val);
        break;
      case 'checkbox' :
      case 'radio' :
        if (val === 'true' || val === 1 || val === '1') val = true;
        if (val === 'false' || val === 0 || val === '0') val = false;
        if (val === true || val === false) this.node.checked = val;
        else this.element.value = val;
        break;
      default:
        this.element.value = val;
        break;
    }
  }

  /**
  *  Get/Set value. It's a chainable alias for the getter/setter
  *  properties
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {number|string} [v] Value to set
  *  @returns {this|boolean|string|undefined}
  *  @see {@link value}
  */
  val(v) {
    if (typeof v !== 'undefined' && v !== null) {
      this.value = v;
      return this;
    }
    return this.value;
  }

  /**
  *  Dirty state
  *
  *  Will be `true` if field value/state have changed
  *  `false` otherwise
  *
  *  @type {boolean}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *  @see https://www.sitepoint.com/detect-html-form-changes/
  */
  get dirty() {
    if (
      this.type === 'checkbox' ||
      this.type === 'radio'
    ) return this.node.checked !== this.node.defaultChecked;
    else if (this.type === 'select') {
      let c = false, def = 0, o, ol, opt;

      for (o = 0, ol = this.node.options.length; o < ol; o++) {
        opt = this.node.options[o];
        c = c || (opt.selected !== opt.defaultSelected);
        if (opt.defaultSelected) def = o;
      }
      if (c && !this.node.multiple) c = (def !== this.node.selectedIndex);
    }
    return this.value !== this.node.defaultValue;
  }

  /**
  *  Add a validation rule. Some presets are available :
  *
  *  - `required` : Value must be not empty. Add a `required` attribute to the Element
  *  - `sameas` : Value must be the same than another field. __You must
  *  provide a CSS Selector to query this field as second argument__
  *  - `int` : Strict integer (only numbers)
  *  - `number` : Decimal number (with dot). Change Element type attribute to `number`
  *  - `email` : E-mail pattern. Change Element type attribute to `email`
  *
  *  For custom error messages when validating through browser or programmatically,
  *  you can set the title attribute of the InputElement or provide a message when
  *  creating rule.
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {string}  name  Name of the rule (preset or custom)
  *  @param {string|RegExp|CSSSelector|InputRuleCallback} [pattern]
  *  - Presets will usually don't require this value
  *  - For preset `sameas`, you must provide the required CSS selector
  *  to find the other Element in order to check value.
  *  - For custom rule, you can provide a valid RegExp object or a callback. Any other value
  *  will be evaluated as a RegExp if fourth parameter is set to `true`.
  *  @param {string}  [message]   A message linked to a non validated rule can be store here
  *  @param {boolean} [evaluate = false]  If `true`, the method will try to evaluate
  *  `pattern`parameter as a regular expression. If `false`, it will simply don't create rule
  *  if `pattern` is not a {@link Function} or a {@link RegExp}.
  *  @returns {this} Chainable
  *  @todo  Add more presets
  */
  rule(name, pattern, message, evaluate = false) {
    // Presets
    switch (name) {
      case 'required':
        this._rules.push('required.pattern', /^(?!\s*$).+/);
        this._rules.push('required.message', message || 'FIELD_REQUIRED');
        this.attr('required', 'required');
        break;
      case 'sameas':
        this._rules.push('sameas.pattern', pattern);
        this._rules.push('sameas.message', message || ('FIELD_NOT_MATCHING_' + pattern));
        break;
      case 'int':
        this._rules.push('int.pattern', /^\-?\d+$/);
        this._rules.push('int.message', message || 'FIELD_NOT_INT');
        this.type = 'number';
        break;
      case 'number':
        this._rules.push('number.pattern', /^\-?\d+\.?\d*$/);
        this._rules.push('number.message', message || 'FIELD_NOT_DECIMAL');
        this.type = 'number';
        break;
      case 'email':
        this._rules.push('email.pattern', /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/); //eslint-disable-line
        this._rules.push('email.message', message || 'FIELD_NOT_EMAIL');
        this.type = 'email';
        break;
      default:
        if (pattern instanceof Function) {
          this._rules.push(name + '.callback', pattern);
          this._rules.push(name + '.message', message);
        } else if (pattern instanceof RegExp || evaluate) {
          this._rules.push(name + '.pattern', new RegExp(pattern));
          this._rules.push(name + '.message', message);
        }
    }
    return this;
  }

  /**
  *  Export rules set on the Element
  *
  *  @type {InputValidationRules}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get rules() {
    return this._rules.data;
  }

  /**
  *  Import rules set on the Element
  *
  *  @type {InputValidationRules}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  set rules(rules) {
    this._rules.import(rules);
  }

  /**
  *  Validate the field against the rules. Any errors will be logged
  *  in {@link errors} property.
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {boolean} `true`if field validates, `false` otherwise.
  */
  validate() {
    // Reset errors
    this._errors.empty();

    // fix : Some older browsers may returns the placeholder as value
    if (this.value === this.attr('placeholder')) this.value = '';

    return this._rules.reduce(function (validate, v, k) {
      if (k === 'sameas') validate = this.value === Q(v.pattern).value;
      else if (v.pattern) validate = v.pattern.test(this.value);
      else validate = v.callback.call(this, this);
      if (!validate) this._errors.push(k, v.message || this.attr('title'));
      return validate;
    }.bind(this), true);
  }

  /**
  *  Fetch validation errors
  *
  *  @type {Object}
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get errors() {
    return this._errors.data;
  }

  /**
  *  Returns a label HtmlElement linked to this inputElement
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {string} t Text for label
  *  @param {KeyValueObject} o Options for HtmLElement
  *  @returns {HtmlElement} Label
  */
  label(t, o) {
    return Q(`+<label for="${this.name}">${t}</label>`, o);
  }

  /**
  *  Returns a tooltip HtmlElement linked to this inputElement
  *
  *  @version 1.0.0
  *  @since 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {$type} t Text for tooltip
  *  @param {KeyValueObject} o Options for HtmLElement
  *  @returns {HtmlElement} Tooltip
  */
  tooltip(t, o) {
    return Q(`+<span data-for="${this.name}" data-type="tooltip">${t}</span>`, o);
  }
}