Home Reference Source

src/TextInput/TextInput.js

/*
 * This file is part of basis-input-masking lib.
 * (c) Basis Europe <eu@basis.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

import StringMask from 'bbj-masks/src/StringMask'
import {
  maskIfNotMasked,
  unmask,
  findCaretPosition,
  generatePatternFromMask,
} from './tools.js'

/**
 * The `TextInput` will wrap text inputs and apply the given [bbj string mask](https://github.com/BasisHub/bbj-masks#string-masks)
 *
 * **Options**
 *
 * _Options can be passed via data attributes . For data attributes, append the option name to data-, as in data-mask_
 *
 * | Option    | Default | Description                                                                                             |
 * |-----------|---------|---------------------------------------------------------------------------------------------------------|
 * | mask |         | The bbj string mask @see [BBj String Masks](https://github.com/BasisHub/bbj-masks#string-masks) |
 *
 *  <br>
 *
 * **Example :**
 * ```html
 *  <input class="bbj-text-masked" name="test" id="test" value="ed23" data-mask="AA-00">
 *
 *  <script>
 *    document.addEventListener('DOMContentLoaded', function (e) {
 *      new Basis.InputMasking.TextInput({
 *         onUpdate: (maskedValue , rawValue , input) => {
 *            // do something
 *         },
 *         onInvalid: (err , input) => {
 *            // do something
 *         }
 *      })
 *    })
 *  </script>
 * ```
 *
 * @author Hyyan Abo Fakher <habofakher@basis.com>
 */
class TextInput {
  /**
   * Construct new TextInput
   *
   * @param {?Object} options - The input options.
   * @param {HTMLElement|String} [options.elements=".bbj-text-masked"] - The class name or the node to use
   * @param {HTMLDocument} [options.document=document] - Document instance to use
   * @param {String} [options.cssClassError="bbj-mask-error"] - A css class to attach to the input when it is invalid
   * @param {String} [options.cssClassSuccess="bbj-mask-success"] - A css class to attach to the input when it is valid after the user interaction
   * @param {Function} [options.onUpdate=null] - A callback to be called on the new masked value is set
   * @param {Function} [options.onInvalid=null] - A callback to be called on the input or the mask is invalid
   */
  constructor(options = {}) {
    this.options = {
      ...{
        elements: '.bbj-text-masked',
        doc: document,
        cssClassError: 'bbj-mask-error',
        cssClassSuccess: 'bbj-mask-success',
        onUpdate: null,
        onInvalid: null,
      },
      ...options,
    }
    this._onKeystroke = this._onKeystroke.bind(this)
    this._onFocus = this._onFocus.bind(this)
    this.refresh()
  }

  /**
   * Initialize the component and wrap the input elements for masking in case
   * they are not wrapped yet
   */
  refresh() {
    const elements =
      typeof this.options.elements === 'string'
        ? this.options.doc.querySelectorAll(this.options.elements)
        : this.options.elements
    let input, parentClass

    for (let i = 0; i < elements.length; i++) {
      input = elements[i]
      if (input instanceof HTMLInputElement) {
        parentClass = input.parentNode.getAttribute('class')

        if (!input.getAttribute('id')) {
          console.warn(
            `BBjMasking: Input has no ID. Without an ID the input cannot be masked`,
            input
          )
          continue
        }

        // we don't initialize the input's wrap twice
        const isWrapped =
          parentClass && parentClass.indexOf('textInputMask__wrap') > -1
        this._wrap(input, isWrapped)
      } else {
        console.warn(
          `BBjMasking: Invalid input element. The element will be ignored`,
          input
        )
      }
    }
  }

  /**
   * Unwrap the input elements and remove attached listeners
   */
  destroy() {
    const elements =
      typeof this.options.elements === 'string'
        ? this.options.doc.querySelectorAll(this.options.elements)
        : this.options.elements

    let input, parent, parentClass

    for (let i = 0; i < elements.length; i++) {
      input = elements[i]
      parent = input.parentNode
      parentClass = parent.getAttribute('class')

      if (parentClass && parentClass.indexOf('textInputMask__wrap') > -1) {
        this._unwrap(parent)
      }
    }
  }

  /**
   * Create the text masking input wrapper
   *
   * @param {HTMLInputElement} input the input element
   * @param {Boolean} isWrapped when true the input is already wrapped and we need to add what is
   *                            missing only
   *
   * @returns {HTMLSpanElement} the wrap element
   *
   * @protected
   */
  _wrap(input, isWrapped) {
    const inputId = input.getAttribute('id'),
      inputName = input.getAttribute('name'),
      mask = input.dataset.mask || '',
      originalPattern = input.pattern,
      defaultPattern = generatePatternFromMask(mask),
      pattern = originalPattern || defaultPattern

    let wrap = null,
      unmaskInput = null

    if (!isWrapped) {
      wrap = document.createElement('span')
      unmaskInput = document.createElement('input') // hidden input with the unmasked values for forms
    } else {
      wrap = input.parentNode
      unmaskInput = wrap.querySelector('.textInputMask__unmaskedInput')
    }

    // configure the actual input
    // -----------------------------------------------------
    input.value = maskIfNotMasked(input.value, mask)
    input.pattern = pattern
    input.classList.add('textInputMask__textInput')
    input.dataset.mask = mask
    input.dataset.valueUnmasked = unmask(input.value, mask)
    if (originalPattern && originalPattern !== defaultPattern) {
      input.dataset.isCustomPattern = true
    }

    if (!isWrapped) {
      if (!(input.hasAttribute('readonly') || input.hasAttribute('disable'))) {
        input.addEventListener('keyup', this._onKeystroke)
        input.addEventListener('keypress', this._onKeystroke)
        input.addEventListener('paste', this._onKeystroke)
        input.addEventListener('focusin', this._onFocus)
        input.addEventListener('click', this._onFocus)
      }
      input.parentNode.insertBefore(wrap, input) // move the input outside the wrapper
    }

    if (this._validateInput(input)) {
      this.__fireOnUpdate(input.value, input.dataset.valueUnmasked, input)
    }

    // configure the unmasked input
    // ----------------------------------------------------
    unmaskInput.setAttribute('aria-hidden', 'true')
    unmaskInput.setAttribute('type', 'hidden')
    unmaskInput.classList.add('textInputMask__unmaskedInput')
    unmaskInput.value = input.dataset.valueUnmasked
    if (inputId) unmaskInput.setAttribute('id', `${inputId}-unmasked`)
    if (inputName) unmaskInput.setAttribute('name', `${inputName}-unmasked`)

    if (!isWrapped) {
      // configure the wrapper
      wrap.setAttribute('class', 'textInputMask__wrap')
      wrap.appendChild(unmaskInput)
      wrap.appendChild(input)
    }

    return wrap
  }

  /**
   * Unwrap the masked input and remove the value changed listener
   *
   * @param {HTMLSpanElement} textInput the wrapper span instance
   *
   * @protected
   */
  _unwrap(textInput) {
    textInput.removeChild(
      textInput.querySelector('.textInputMask__unmaskedInput')
    )

    const input = textInput.querySelector('.textInputMask__textInput')
    input.removeEventListener('keyup', this._onKeystroke)
    input.removeEventListener('keypress', this._onKeystroke)
    input.removeEventListener('paste', this._onKeystroke)
    input.removeEventListener('focusin', this._onFocus)
    input.removeEventListener('click', this._onFocus)
    delete input.dataset.valueUnmasked
    if (!input.dataset.isCustomPattern) {
      input.removeAttribute('pattern')
      delete input.dataset.isCustomPattern
    }

    input.classList.remove(this.options.cssClassError)

    textInput.parentNode.insertBefore(input, textInput)
    textInput.parentNode.removeChild(textInput)
  }

  /**
   * Listen to every keystroke on the input and update the masked and the unmasked value
   *
   * @param {Event} e
   *
   * @protected
   */
  _onKeystroke(e) {
    if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return
    if(e.keyCode !== 13) e.preventDefault()
    
    const input = e.target,
      mask = input.dataset.mask || this.options.mask,
      eventType = e.type

    input.classList.remove(this.options.cssClassError)
    input.classList.remove(this.options.cssClassSuccess)
    input.setCustomValidity('');

    let value = input.value,
      keyCode = e.keyCode,
      keyContent = e.key || e.code.replace(/[^0-9]/g, ''),
      key = keyContent ? (keyContent.length > 1 ? '' : keyContent) : '',
      insertPosition = findCaretPosition(value, mask),
      newValue,
      unmaskedValue,
      maskError = false

    switch (eventType) {
      case 'paste':
        newValue = maskIfNotMasked(
          (e.clipboardData || window.clipboardData).getData('Text'),
          mask
        )
        break
      case 'keyup':
        newValue = value
        break
      case 'keypress':
        const selectionStart = input.selectionStart
        if (selectionStart !== insertPosition) insertPosition = selectionStart

        newValue =
          value.substr(0, insertPosition) + key + value.substr(insertPosition)
        break
      default:
        break
    }

    unmaskedValue = unmask(newValue, mask)

    if ([35, 36, 37, 38, 39, 40].indexOf(keyCode) === -1) {
      try {
        input.value = StringMask.mask(unmaskedValue, mask, false)
        if(this._validateInput(input)) {
          input.dataset.valueUnmasked = unmaskedValue
          this.options.doc.querySelector(
            `#${input.getAttribute('id')}-unmasked`
          ).value = unmaskedValue
          this.__applyCssClassState(input, 'success')
          this.__fireOnUpdate(input.value, input.dataset.valueUnmasked, input)
        }
        maskError = false
      } catch (error) {
        this.__applyCssClassState(input, 'error')
        this.__fireOnInvalid(error, input)
        maskError = true
      }

      this._updateCaretPosition(input, mask)
    }

    if (!maskError) this._validateInput(input)
  }

  /**
   * Listen to focus events on the input and update the caret position
   * where the next char should be inserted according to the mask
   *
   * @param {FocusEvent} e
   *
   * @protected
   */
  _onFocus(e) {
    const input = e.target
    const mask = input.dataset.mask || this.options.mask
    this._updateCaretPosition(input, mask)
  }

  /**
   * Update the caret position on the input based on the given mask
   *
   * @param {HTMLInputElement} input instance
   * @param {String} mask  bbj string
   *
   * @protected
   */
  _updateCaretPosition(input, mask) {
    setTimeout(() => {
      const position = findCaretPosition(input.value, mask)
      input.setSelectionRange(position, position)
    }, 0)
  }

  /**
   * Trigger `checkValidity` on the input
   *
   * @param {HTMLInputElement} input
   *
   * @returns {Boolean} true when valid , false otherwise
   *
   * @protected
   */
  _validateInput(input) {
    const isValid = input.checkValidity()

    if (isValid) {
      this.__applyCssClassState(input, 'success')
      input.setCustomValidity('')
    } else {
      this.__applyCssClassState(input, 'error')
      this.__fireOnInvalid(input.validationMessage, input)
    }

    return isValid
  }

  /**
   * @private
   */
  __fireOnUpdate(valueMasked, valueUnmasked, input) {
    if (this.options.onUpdate) {
      this.options.onUpdate(valueMasked, valueUnmasked, input)
    }
  }

  /**
   * @private
   */
  __fireOnInvalid(error, input) {
    if (this.options.onInvalid) {
      this.options.onInvalid(error, input)
    }
  }

  /**
   * @private
   */
  __applyCssClassState(input, state) {
    if (input.hasAttribute('readonly') || input.hasAttribute('disabled')) {
      input.classList.remove(this.options.cssClassError)
      input.classList.remove(this.options.cssClassSuccess)
    } else {
      if (state === 'success') {
        input.classList.remove(this.options.cssClassError)
        input.classList.add(this.options.cssClassSuccess)
      }

      if (state === 'error') {
        input.classList.add(this.options.cssClassError)
        input.classList.remove(this.options.cssClassSuccess)
      }
    }
  }
}

export default TextInput