src/NumberInput/NumberInput.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 NumberMask from 'bbj-masks/src/NumberMask'
const countDecimals = value => {
if (value % 1 != 0) {
const split = value.toString().split('.')
if (split.length === 2) return split[1].length
}
return 0
}
/**
* The `NumberInput` will wrap text inputs and apply the given [bbj Number mask](https://github.com/BasisHub/bbj-masks#number-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 number mask @see [BBj Number Masks](https://github.com/BasisHub/bbj-masks#number-masks) |
* | min | | The maximum value to accept for this input|
* | max | | The minimum value to accept for this input|
* | step | | A stepping interval to use when using up and down arrows to adjust the value, as well as for validation|
* |grouping-separator | , | a char which will be used as a grouping separator |
* |decimal-separator | . | a char which will be used as a decimal separator |
* |force-trailing-zeros | false | Affects the output by switching the way a mask with "#" characters in the trailing positions is filled. for example, the function NumberMask.mask(.10:"#.##") returns .10 instead of .1|
*
* <br>
*
* **Example :**
* ```html
* <input class="bbj-number-masked" name="test" id="test" value="1234" data-mask="##,##0">
*
* <script>
* document.addEventListener('DOMContentLoaded', function (e) {
* new Basis.InputMasking.NumberInput({
*
* // @param {String} valueMasked masked value
* // @param {Number} valueUnmasked original value
* // @param {HTMLInputElement} input the actual input instance
* onUpdate: (valueMasked, valueUnmasked, input, isApplied, isInitial) => {
* // do something
* },
*
* // @param {String|Object} error last occurred error. could be mask error or validation error
* // @param {HTMLInputElement} input the actual input instance
* onInvalid: (err , input) => {
* // do something
* }
* })
* })
* </script>
* ```
*
* @author Hyyan Abo Fakher <habofakher@basis.com>
*/
class NumberInput {
/**
* Construct new NumberInput
*
* @param {?Object} options - The input options.
* @param {HTMLElement|String} [options.elements=".bbj-number-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-number-masked',
doc: document,
cssClassError: 'bbj-mask-error',
cssClassSuccess: 'bbj-mask-success',
onUpdate: null,
onInvalid: null,
},
...options,
}
this._actualInputHandler = this._actualInputHandler.bind(this)
this._unmaskedInputHandler = this._unmaskedInputHandler.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('numberInputMask__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('numberInputMask__wrap') > -1) {
this._unwrap(parent)
}
}
}
/**
* Create the number masking input wrapper
*
* @param {HTMLInputElement} actualInput the input element
* @param {Boolean} isWrapped when true the input is already wrapped and we need to add what is
* missing only
*
* @returns {HTMLSpanElement} wrapper instance
*
* @protected
*/
_wrap(actualInput, isWrapped) {
const actualInputId = actualInput.getAttribute('id'),
actualInputName = actualInput.getAttribute('name'),
actualInputStep = actualInput.dataset.step || null,
actualInputMax = actualInput.dataset.max || null,
actualInputMin = actualInput.dataset.min || null,
actualInputGroupingSeparator =
actualInput.dataset.groupingSeparator || ',',
actualInputDecimalSeparator = actualInput.dataset.decimalSeparator || '.',
actualInputForceTrailingZeros =
actualInput.dataset.forceTrailingZeros || null,
actualInputMask = actualInput.dataset.mask || '',
actualInputClasses = actualInput.getAttribute('class'),
actualInputRequired = actualInput.getAttribute('required') || null
let wrap = null,
unmaskedInput = null
if (!isWrapped) {
wrap = this.options.doc.createElement('span')
unmaskedInput = this.options.doc.createElement('input') // hidden input with the unmasked values for forms
} else {
wrap = actualInput.parentNode
unmaskedInput = wrap.querySelector('.numberInputMask__unmaskedInput')
}
// configure the actual input
// -----------------------------------------------------
if (!isWrapped) {
actualInput.parentNode.insertBefore(wrap, actualInput) // move the input outside the wrapper
if (
!(
actualInput.hasAttribute('readonly') ||
actualInput.hasAttribute('disabled')
)
) {
actualInput.addEventListener('click', this._actualInputHandler)
actualInput.addEventListener('focusin', this._actualInputHandler)
}
}
actualInput.dataset.valueUnmasked = actualInput.value || 0
actualInput.dataset.groupingSeparator = actualInputGroupingSeparator
actualInput.dataset.decimalSeparator = actualInputDecimalSeparator
actualInput.dataset.forceTrailingZeros = actualInputForceTrailingZeros
actualInput.value = NumberMask.mask(
actualInput.dataset.valueUnmasked,
actualInputMask,
actualInputGroupingSeparator,
actualInputDecimalSeparator,
actualInputForceTrailingZeros
).trim()
actualInput.classList.add(
'numberInputMask__textInput',
this.options.cssClassSuccess
)
// configure the unmasked input
// ----------------------------------------------------
unmaskedInput.value = actualInput.dataset.valueUnmasked
unmaskedInput.setAttribute('aria-hidden', 'true')
unmaskedInput.setAttribute('type', 'hidden')
unmaskedInput.setAttribute('class', actualInputClasses)
if (actualInputId)
unmaskedInput.setAttribute('id', `${actualInputId}-unmasked`)
if (actualInputName)
unmaskedInput.setAttribute('name', `${actualInputName}-unmasked`)
if (actualInputRequired) unmaskedInput.setAttribute('required', 'required')
if (actualInputStep) unmaskedInput.setAttribute('step', actualInputStep)
else {
const decimals = countDecimals(unmaskedInput.value)
let step = '1'
if (decimals > 0) {
step = `.${Array(decimals).join('0')}1`
}
unmaskedInput.setAttribute('step', step)
}
if (actualInputMin) unmaskedInput.setAttribute('min', actualInputMin)
if (actualInputMax) unmaskedInput.setAttribute('max', actualInputMax)
unmaskedInput.dataset.inputId = actualInputId
unmaskedInput.dataset.mask = actualInputMask
unmaskedInput.dataset.groupingSeparator = actualInputGroupingSeparator
unmaskedInput.dataset.decimalSeparator = actualInputDecimalSeparator
if (actualInputForceTrailingZeros)
unmaskedInput.dataset.forceTrailingZeros = actualInputForceTrailingZeros
if (!isWrapped) {
unmaskedInput.classList.add('numberInputMask__unmaskedInput')
unmaskedInput.addEventListener('keydown', this._unmaskedInputHandler)
unmaskedInput.addEventListener('keyup', this._unmaskedInputHandler)
unmaskedInput.addEventListener('focusout', this._unmaskedInputHandler)
// configure the wrapper
wrap.setAttribute('class', 'numberInputMask__wrap')
wrap.appendChild(unmaskedInput)
wrap.appendChild(actualInput)
}
if (!isNaN(Number(actualInput.dataset.valueUnmasked))) {
if (this._validateInput(unmaskedInput, actualInput)) {
this.__fireOnUpdate(
actualInput.value,
actualInput.dataset.valueUnmasked,
actualInput
)
}
} else {
actualInput.classList.add(this.options.cssClassError)
}
return wrap
}
/**
* Unwrap the masked input and remove the value changed listener
*
* @param {HTMLSpanElement} wrapper the wrapper span instance
*
* @protected
*/
_unwrap(wrapper) {
const actualInput = wrapper.querySelector('.numberInputMask__textInput'),
actualInputId = actualInput.id,
unmaskedInput = wrapper.querySelector(`#${actualInputId}-unmasked`)
unmaskedInput.removeEventListener('keyup', this._unmaskedInputHandler)
unmaskedInput.removeEventListener('keypress', this._unmaskedInputHandler)
unmaskedInput.removeEventListener('focusout', this._unmaskedInputHandler)
wrapper.removeChild(unmaskedInput)
actualInput.removeEventListener('click', this._actualInputHandler)
actualInput.removeEventListener('focusin', this._actualInputHandler)
actualInput.classList.remove('numberInputMask__textInput')
actualInput.classList.remove(this.options.cssClassError)
actualInput.classList.remove(this.options.cssClassSuccess)
delete actualInput.dataset.valueUnmasked
wrapper.parentNode.insertBefore(actualInput, wrapper)
wrapper.parentNode.removeChild(wrapper)
}
/**
* Listen to click and focusin event on the actual input and toggle the number input
*
* @param {Event} e
*
* @protected
*/
_actualInputHandler(e) {
const actualInput = e.target,
actualInputId = actualInput.id,
unmaskedInput = this.options.doc.querySelector(
`#${actualInputId}-unmasked`
)
actualInput.setAttribute('aria-hidden', 'true')
actualInput.setAttribute('type', 'hidden')
unmaskedInput.removeAttribute('aria-hidden')
unmaskedInput.setAttribute('type', 'number')
this._validateInput(unmaskedInput, actualInput)
setTimeout(() => {
unmaskedInput.focus()
const length = String(unmaskedInput.value).length
unmaskedInput.type = 'text'
unmaskedInput.setSelectionRange(length, length)
unmaskedInput.type = 'number'
}, 0)
}
/**
* Listen to the unmasked input keydown and focusout events and check
* if the input value can be masked or not
*
* @param {Event} e
*
* @protected
*/
_unmaskedInputHandler(e) {
const unmaskedInput = e.target,
keyCode = e.keyCode,
mask = unmaskedInput.dataset.mask,
groupingSeparator = unmaskedInput.dataset.groupingSeparator,
decimalSeparator = unmaskedInput.dataset.decimalSeparator,
forceTrailingZeros = unmaskedInput.dataset.forceTrailingZeros,
actualInputId = unmaskedInput.dataset.inputId,
actualInput = this.options.doc.querySelector(`#${actualInputId}`)
let restore = false,
apply = false,
maskedValue = false,
isValid = this._validateInput(unmaskedInput, actualInput)
try {
maskedValue = NumberMask.mask(
unmaskedInput.value || 0,
mask,
groupingSeparator,
decimalSeparator,
forceTrailingZeros,
false
).trim()
} catch (e) {
maskedValue = false
this.__applyCssClassState(unmaskedInput, actualInput, 'error')
this.__fireOnInvalid(e, actualInput)
}
restore = [13, 27].indexOf(keyCode) > -1 || e.type === 'focusout'
apply = maskedValue && isValid
if (restore) {
unmaskedInput.classList.remove(this.options.cssClassError)
unmaskedInput.classList.remove(this.options.cssClassSuccess)
unmaskedInput.setAttribute('aria-hidden', 'true')
unmaskedInput.setAttribute('type', 'hidden')
actualInput.removeAttribute('aria-hidden')
actualInput.setAttribute('type', 'text')
actualInput.classList.add(this.options.cssClassSuccess)
if (apply) {
actualInput.value = maskedValue
actualInput.dataset.valueUnmasked = unmaskedInput.value
this.__fireOnUpdate(maskedValue, unmaskedInput.value, actualInput)
} else {
unmaskedInput.value = actualInput.dataset.valueUnmasked
this.__applyCssClassState(unmaskedInput, actualInput, 'success')
}
}
}
/**
* Trigger `checkValidity` on the input
*
* @param {HTMLInputElement} unmaskedInput
* @param {HTMLInputElement} actualInput
*
* @returns {Boolean} true when valid , false otherwise
*
* @protected
*/
_validateInput(unmaskedInput, actualInput) {
let isValid = true
const value = Number(unmaskedInput.value)
if (unmaskedInput.getAttribute('type') === 'hidden' && !isNaN(value)) {
const max = unmaskedInput.getAttribute('max')
const min = unmaskedInput.getAttribute('min')
const step = unmaskedInput.getAttribute('step')
if (min) isValid = isValid && value >= Number(min)
if (max) isValid = isValid && value <= Number(max)
if (step)
isValid =
isValid && countDecimals(step) === countDecimals(unmaskedInput.value)
} else isValid = unmaskedInput.checkValidity()
if (isValid) {
this.__applyCssClassState(unmaskedInput, actualInput, 'success')
} else {
this.__applyCssClassState(unmaskedInput, actualInput, 'error')
this.__fireOnInvalid(
unmaskedInput.validationMessage || 'Validity check fails',
actualInput
)
}
return isValid
}
/**
* @param {String} valueMasked masked value
* @param {Number} valueUnmasked original value
* @param {HTMLInputElement} input the actual input instance
*
* @private
*/
__fireOnUpdate(valueMasked, valueUnmasked, input) {
if (this.options.onUpdate) {
this.options.onUpdate(valueMasked, valueUnmasked, input)
}
}
/**
* @param {String|Object} error last occurred error. could be mask error or validation error
* @param {HTMLInputElement} input the actual input instance
*
* @private
*/
__fireOnInvalid(error, input) {
if (this.options.onInvalid) {
this.options.onInvalid(error, input)
}
}
/**
* @private
*/
__applyCssClassState(unmaskedInput, actualInput, state) {
if (
actualInput.hasAttribute('readonly') ||
actualInput.hasAttribute('disabled')
) {
actualInput.classList.remove(this.options.cssClassError)
actualInput.classList.remove(this.options.cssClassSuccess)
unmaskedInput.classList.remove(this.options.cssClassError)
unmaskedInput.classList.remove(this.options.cssClassSuccess)
} else {
if (state === 'success') {
actualInput.classList.remove(this.options.cssClassError)
actualInput.classList.add(this.options.cssClassSuccess)
unmaskedInput.classList.remove(this.options.cssClassError)
unmaskedInput.classList.add(this.options.cssClassSuccess)
}
if (state === 'error') {
actualInput.classList.add(this.options.cssClassError)
actualInput.classList.remove(this.options.cssClassSuccess)
unmaskedInput.classList.add(this.options.cssClassError)
unmaskedInput.classList.remove(this.options.cssClassSuccess)
}
}
}
}
export default NumberInput