UNPKG

imask

Version:

vanilla javascript input mask

351 lines (312 loc) 10.5 kB
import { DIRECTION } from '../core/utils.js'; import ActionDetails from '../core/action-details.js'; import createMask, { maskedClass } from '../masked/factory.js'; import MaskElement from './mask-element.js'; import HTMLInputMaskElement from './html-input-mask-element.js'; import HTMLContenteditableMaskElement from './html-contenteditable-mask-element.js'; import IMask from '../core/holder.js'; import InputHistory from './input-history.js'; import './html-mask-element.js'; /** Listens to element events and controls changes between element and {@link Masked} */ class InputMask { /** View element */ /** Internal {@link Masked} model */ constructor(el, opts) { this.el = el instanceof MaskElement ? el : el.isContentEditable && el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' ? new HTMLContenteditableMaskElement(el) : new HTMLInputMaskElement(el); this.masked = createMask(opts); this._listeners = {}; this._value = ''; this._unmaskedValue = ''; this._rawInputValue = ''; this.history = new InputHistory(); this._saveSelection = this._saveSelection.bind(this); this._onInput = this._onInput.bind(this); this._onChange = this._onChange.bind(this); this._onDrop = this._onDrop.bind(this); this._onFocus = this._onFocus.bind(this); this._onClick = this._onClick.bind(this); this._onUndo = this._onUndo.bind(this); this._onRedo = this._onRedo.bind(this); this.alignCursor = this.alignCursor.bind(this); this.alignCursorFriendly = this.alignCursorFriendly.bind(this); this._bindEvents(); // refresh this.updateValue(); this._onChange(); } maskEquals(mask) { var _this$masked; return mask == null || ((_this$masked = this.masked) == null ? void 0 : _this$masked.maskEquals(mask)); } /** Masked */ get mask() { return this.masked.mask; } set mask(mask) { if (this.maskEquals(mask)) return; if (!(mask instanceof IMask.Masked) && this.masked.constructor === maskedClass(mask)) { // TODO "any" no idea this.masked.updateOptions({ mask }); return; } const masked = mask instanceof IMask.Masked ? mask : createMask({ mask }); masked.unmaskedValue = this.masked.unmaskedValue; this.masked = masked; } /** Raw value */ get value() { return this._value; } set value(str) { if (this.value === str) return; this.masked.value = str; this.updateControl('auto'); } /** Unmasked value */ get unmaskedValue() { return this._unmaskedValue; } set unmaskedValue(str) { if (this.unmaskedValue === str) return; this.masked.unmaskedValue = str; this.updateControl('auto'); } /** Raw input value */ get rawInputValue() { return this._rawInputValue; } set rawInputValue(str) { if (this.rawInputValue === str) return; this.masked.rawInputValue = str; this.updateControl(); this.alignCursor(); } /** Typed unmasked value */ get typedValue() { return this.masked.typedValue; } set typedValue(val) { if (this.masked.typedValueEquals(val)) return; this.masked.typedValue = val; this.updateControl('auto'); } /** Display value */ get displayValue() { return this.masked.displayValue; } /** Starts listening to element events */ _bindEvents() { this.el.bindEvents({ selectionChange: this._saveSelection, input: this._onInput, drop: this._onDrop, click: this._onClick, focus: this._onFocus, commit: this._onChange, undo: this._onUndo, redo: this._onRedo }); } /** Stops listening to element events */ _unbindEvents() { if (this.el) this.el.unbindEvents(); } /** Fires custom event */ _fireEvent(ev, e) { const listeners = this._listeners[ev]; if (!listeners) return; listeners.forEach(l => l(e)); } /** Current selection start */ get selectionStart() { return this._cursorChanging ? this._changingCursorPos : this.el.selectionStart; } /** Current cursor position */ get cursorPos() { return this._cursorChanging ? this._changingCursorPos : this.el.selectionEnd; } set cursorPos(pos) { if (!this.el || !this.el.isActive) return; this.el.select(pos, pos); this._saveSelection(); } /** Stores current selection */ _saveSelection( /* ev */ ) { if (this.displayValue !== this.el.value) { console.warn('Element value was changed outside of mask. Syncronize mask using `mask.updateValue()` to work properly.'); // eslint-disable-line no-console } this._selection = { start: this.selectionStart, end: this.cursorPos }; } /** Syncronizes model value from view */ updateValue() { this.masked.value = this.el.value; this._value = this.masked.value; this._unmaskedValue = this.masked.unmaskedValue; this._rawInputValue = this.masked.rawInputValue; } /** Syncronizes view from model value, fires change events */ updateControl(cursorPos) { const newUnmaskedValue = this.masked.unmaskedValue; const newValue = this.masked.value; const newRawInputValue = this.masked.rawInputValue; const newDisplayValue = this.displayValue; const isChanged = this.unmaskedValue !== newUnmaskedValue || this.value !== newValue || this._rawInputValue !== newRawInputValue; this._unmaskedValue = newUnmaskedValue; this._value = newValue; this._rawInputValue = newRawInputValue; if (this.el.value !== newDisplayValue) this.el.value = newDisplayValue; if (cursorPos === 'auto') this.alignCursor();else if (cursorPos != null) this.cursorPos = cursorPos; if (isChanged) this._fireChangeEvents(); if (!this._historyChanging && (isChanged || this.history.isEmpty)) this.history.push({ unmaskedValue: newUnmaskedValue, selection: { start: this.selectionStart, end: this.cursorPos } }); } /** Updates options with deep equal check, recreates {@link Masked} model if mask type changes */ updateOptions(opts) { const { mask, ...restOpts } = opts; // TODO types, yes, mask is optional const updateMask = !this.maskEquals(mask); const updateOpts = this.masked.optionsIsChanged(restOpts); if (updateMask) this.mask = mask; if (updateOpts) this.masked.updateOptions(restOpts); // TODO if (updateMask || updateOpts) this.updateControl(); } /** Updates cursor */ updateCursor(cursorPos) { if (cursorPos == null) return; this.cursorPos = cursorPos; // also queue change cursor for mobile browsers this._delayUpdateCursor(cursorPos); } /** Delays cursor update to support mobile browsers */ _delayUpdateCursor(cursorPos) { this._abortUpdateCursor(); this._changingCursorPos = cursorPos; this._cursorChanging = setTimeout(() => { if (!this.el) return; // if was destroyed this.cursorPos = this._changingCursorPos; this._abortUpdateCursor(); }, 10); } /** Fires custom events */ _fireChangeEvents() { this._fireEvent('accept', this._inputEvent); if (this.masked.isComplete) this._fireEvent('complete', this._inputEvent); } /** Aborts delayed cursor update */ _abortUpdateCursor() { if (this._cursorChanging) { clearTimeout(this._cursorChanging); delete this._cursorChanging; } } /** Aligns cursor to nearest available position */ alignCursor() { this.cursorPos = this.masked.nearestInputPos(this.masked.nearestInputPos(this.cursorPos, DIRECTION.LEFT)); } /** Aligns cursor only if selection is empty */ alignCursorFriendly() { if (this.selectionStart !== this.cursorPos) return; // skip if range is selected this.alignCursor(); } /** Adds listener on custom event */ on(ev, handler) { if (!this._listeners[ev]) this._listeners[ev] = []; this._listeners[ev].push(handler); return this; } /** Removes custom event listener */ off(ev, handler) { if (!this._listeners[ev]) return this; if (!handler) { delete this._listeners[ev]; return this; } const hIndex = this._listeners[ev].indexOf(handler); if (hIndex >= 0) this._listeners[ev].splice(hIndex, 1); return this; } /** Handles view input event */ _onInput(e) { this._inputEvent = e; this._abortUpdateCursor(); const details = new ActionDetails({ // new state value: this.el.value, cursorPos: this.cursorPos, // old state oldValue: this.displayValue, oldSelection: this._selection }); const oldRawValue = this.masked.rawInputValue; const offset = this.masked.splice(details.startChangePos, details.removed.length, details.inserted, details.removeDirection, { input: true, raw: true }).offset; // force align in remove direction only if no input chars were removed // otherwise we still need to align with NONE (to get out from fixed symbols for instance) const removeDirection = oldRawValue === this.masked.rawInputValue ? details.removeDirection : DIRECTION.NONE; let cursorPos = this.masked.nearestInputPos(details.startChangePos + offset, removeDirection); if (removeDirection !== DIRECTION.NONE) cursorPos = this.masked.nearestInputPos(cursorPos, DIRECTION.NONE); this.updateControl(cursorPos); delete this._inputEvent; } /** Handles view change event and commits model value */ _onChange() { if (this.displayValue !== this.el.value) this.updateValue(); this.masked.doCommit(); this.updateControl(); this._saveSelection(); } /** Handles view drop event, prevents by default */ _onDrop(ev) { ev.preventDefault(); ev.stopPropagation(); } /** Restore last selection on focus */ _onFocus(ev) { this.alignCursorFriendly(); } /** Restore last selection on focus */ _onClick(ev) { this.alignCursorFriendly(); } _onUndo() { this._applyHistoryState(this.history.undo()); } _onRedo() { this._applyHistoryState(this.history.redo()); } _applyHistoryState(state) { if (!state) return; this._historyChanging = true; this.unmaskedValue = state.unmaskedValue; this.el.select(state.selection.start, state.selection.end); this._saveSelection(); this._historyChanging = false; } /** Unbind view events and removes element reference */ destroy() { this._unbindEvents(); this._listeners.length = 0; delete this.el; } } IMask.InputMask = InputMask; export { InputMask as default };