UNPKG

rxmask

Version:

Simple, but advanced mask parser for html input or parsing provided string directly

318 lines (292 loc) 11.9 kB
export interface InputOptions { mask?: string; placeholderSymbol?: string; rxmask?: string; value?: string; cursorPos?: number; allowedCharacters?: string; maxMaskLength?: string; trailing?: string; } export interface Options { mask: string; placeholderSymbol: string; rxmask: string[]; value: string; cursorPos: number; allowedCharacters: string; maxMaskLength: number; trailing: boolean; } export interface RXError { symbol: string; position: number; type: 'allowedCharacters' | 'length' | 'rxmask'; } export default class Parser { options: Options = { mask: '', placeholderSymbol: '*', rxmask: [], value: '', cursorPos: 0, allowedCharacters: '.', maxMaskLength: 0, trailing: true }; input: HTMLTextAreaElement | HTMLInputElement | null | undefined; errors: RXError[] = []; private _output: string = ''; private _parsedValue: string = ''; private _prevValue: string = ''; private _isRemovingSymbols: boolean = false; private _actualCursorPos: number = 0; private _finalCursorPos: number = 0; constructor(options: InputOptions = {}, input?: HTMLTextAreaElement | HTMLInputElement | null | undefined) { this.input = input; this.setOptions(options); if (this.input) { this.onInput(); } else { this.parseMask(); } } get output() { return this._output; } get parsedValue() { return this._parsedValue; } get finalCursorPos() { return this._finalCursorPos; } /** * Takes options from provided option values * @param {InputOptions} options Options to set */ setOptions({ mask = '', placeholderSymbol = '*', rxmask = '', allowedCharacters = '.', maxMaskLength = '0', trailing = 'true', value = '', cursorPos = -1 }: InputOptions) { this.options.mask = mask; this.options.placeholderSymbol = placeholderSymbol; this.options.rxmask = this.strToRxmask(rxmask); this.options.allowedCharacters = allowedCharacters; this.options.maxMaskLength = Number(maxMaskLength); this.options.trailing = trailing === 'true'; this.options.value = value; this.options.cursorPos = cursorPos; // Parse rxmask in the end if (this.options.rxmask.length === 0) { this.options.rxmask = this.options.mask.split('').map(char => { if (char === this.options.placeholderSymbol) return '[^]'; return char; }); } } /** * Takes options from provided input value (if present), otherwise sets previous values */ private parseOptionsFromInput() { if (this.input) { const data = this.input.dataset; this.options.mask = data.mask || this.options.mask; this.options.placeholderSymbol = data.placeholdersymbol || this.options.placeholderSymbol; this.options.rxmask = data.rxmask ? this.strToRxmask(data.rxmask) : this.options.rxmask; this.options.allowedCharacters = data.allowedcharacters || this.options.allowedCharacters; this.options.maxMaskLength = data.maxmasklength !== undefined ? Number(data.maxmasklength) : this.options.maxMaskLength; this.options.trailing = data.trailing !== undefined ? data.trailing === 'true' : this.options.trailing; this.options.value = this.input.value; this.options.cursorPos = this.input.selectionStart || 0; // Parse rxmask in the end if (this.options.rxmask.length === 0) { this.options.rxmask = this.options.mask.split('').map(char => { if (char === this.options.placeholderSymbol) return '[^]'; return char; }); } } } /** * If this method is called, it will cause options update (with this.input values), call of this.parseMask() * and update of new value of this.input (this.input.value) and cursor position (this.input.setSelectionRange) * according to changes introduced by this.parseMask() */ onInput() { // Set options - in that case it will take all possible options from input element this.parseOptionsFromInput(); // Parse values this.parseMask(); // Everything is parsed, set output and cursorPos if (this.input) { this.input.value = this.output; this.input.setSelectionRange(this.finalCursorPos, this.finalCursorPos); } } /** * Call this to update this.output and this.finalCursorPos according to options currently provided in this.options */ parseMask() { this.errors = []; const noMaskValue = this.parseOutMask(); const parsedValue = this.parseRxmask(noMaskValue); this._parsedValue = parsedValue; this._output = this.getOutput(parsedValue); this._prevValue = this._output; } // Idea here is to parse everything before cursor position as is, // but parse everything after cursor as if it was shifted by inserting some symbols on cursor position. // This method is trying to remove mask symbols, but it still leaves symbols that are not allowed // Example ("|" is a cursor position): // Mask is ***-**-** and value before input is 123-4|5-6, then user enters 7, so input is initially (before parsing) is 123-47|5-6 // 123-47 parsed as-is (without shift or diff), so output for beforeCursor is 12347 // Position of 5-6 is correlates to -** (or, with beforeCursor part ...-..5-6 is correlates to ***-**-**) for mask, // so if it will be parsed without shift, it will result in wrong value (-6) // Because of that this value is shifted back for one symbol (for as much symbols as were entered or deleted by user) for mask, // so 5-6 is now correlates to *-* (or, with beforeCursor part ...-.5-6. is correlates to ***-**-**). Now afterCursor is parsed correctly as 56. private parseOutMask() { const { value, cursorPos, rxmask, placeholderSymbol, allowedCharacters } = this.options; // Get length diff between old and current value const diff = value.length - this._prevValue.length; this._isRemovingSymbols = diff >= 0 ? false : true; let parsedAllowedCharacters = /./; try { parsedAllowedCharacters = new RegExp(allowedCharacters); } catch (error) { console.error('Wrong regex for allowedCharacters!'); } // Get value after cursor without mask symbols let afterCursor = ''; for (let i = cursorPos; i < value.length; i++) { // Diff used here to "shift" mask to position where it supposed to be if (value[i] !== rxmask[i - diff] && value[i] !== placeholderSymbol && value[i].match(parsedAllowedCharacters)) { afterCursor += value[i]; } } // Get value before cursor without mask symbols let beforeCursor = ''; for (let i = 0; i < cursorPos; i++) { if (value[i] !== rxmask[i]) { if (value[i] !== placeholderSymbol && value[i].match(parsedAllowedCharacters)) { // If parsed value length before cursor so far less than // amount of allowed symbols in rxmask minus parsed value length after cursor, add symbol if (beforeCursor.length < rxmask.filter(pattern => pattern.match(/\[.*\]/)).length - afterCursor.length) { beforeCursor += value[i]; } else { this.errors.push({ symbol: value[i], position: i, type: 'length' }); } } else if (value[i] !== placeholderSymbol && !value[i].match(parsedAllowedCharacters)) { this.errors.push({ symbol: value[i], position: i, type: 'allowedCharacters' }); } } } this._actualCursorPos = beforeCursor.length; // it holds position of cursor after input was parsed return beforeCursor + afterCursor; } private parseRxmask([...noMaskValue]: string) { const { rxmask } = this.options; let parsedValue = ''; const filteredRxmask = rxmask.filter(pattern => pattern.match(/\[.*\]/)); let correctCount = 0; let incorrectCount = 0; while (noMaskValue.length > 0 && correctCount < noMaskValue.length) { let regexChar = /./; try { regexChar = new RegExp(filteredRxmask[correctCount]); } catch (error) { console.error('Wrong regex for rxmask!'); } if (noMaskValue[correctCount].match(regexChar)) { parsedValue += noMaskValue[correctCount]; correctCount++; } else { this.errors.push({ symbol: noMaskValue[correctCount], position: correctCount + incorrectCount, type: 'rxmask' }); noMaskValue.shift(); incorrectCount++; // This line returns cursor to appropriate position according to removed elements if (this._actualCursorPos > correctCount) this._actualCursorPos--; } } return parsedValue; } private getOutput([...parsedValue]: string) { const { rxmask, maxMaskLength, placeholderSymbol, trailing } = this.options; this._finalCursorPos = 0; // Reset value let output = ''; const parsedValueEmpty = parsedValue.length === 0; const isMaskFilled = rxmask.filter(pattern => pattern.match(/\[.*\]/)).length === parsedValue.length; let encounteredPlaceholder = false; // stores if loop found a placeholder at least once for (let i = 0; i < rxmask.length; i++) { // This condition checks if placeholder was found if (rxmask[i].match(/\[.*\]/)) { if (parsedValue.length > 0) { output += parsedValue.shift(); } else if (maxMaskLength > i) { output += placeholderSymbol; encounteredPlaceholder = true; } else { break; } if (this._actualCursorPos > 0) this._finalCursorPos++; this._actualCursorPos--; // reduce this because one symbol or placeholder was added } else { // Add mask symbol if if ( // mask is not fully shown according to this.maxMaskLength maxMaskLength > i || // OR there's some parsed characters left to add parsedValue.length > 0 || // OR this mask symbol is following parsedValue character AND user just added symbols (not removed) // AND (trailing should be enabled OR mask is filled, then add trailing symbols anyway) - see example in README under `trailing` option ((trailing || isMaskFilled) && !encounteredPlaceholder && !this._isRemovingSymbols) ) { output += rxmask[i]; } else { break; } // Add 1 to cursorPos if if ( // no placeholder was encountered AND parsedValue is empty AND this mask symbol should be shown // (this ensures that cursor position will be always set just before first placeholder if parsedValue is empty) (!encounteredPlaceholder && parsedValueEmpty && maxMaskLength > i) || // OR according to _actualCursorPos not all characters from parsedValue before cursorPos were added yet this._actualCursorPos > 0 || // OR all characters from parsedValue before cursorPos were added AND no placeholders yet (or _actualCursorPos will be negative) // AND user just added symbols (see example in README under `trailing` option) (trailing && this._actualCursorPos === 0 && !this._isRemovingSymbols) ) { this._finalCursorPos++; } } } return output; } /** * Converts string representation of rxmask to array * @param {string | null | undefined} str rxmask string representation or null or undefined * @return {string[]} parsed rxmask or empty array */ private strToRxmask(str: string | null | undefined) { return (str || '').match(/(\[.*?\])|(.)/g) || []; } } (function processInputs() { const DOMInputs = <HTMLCollectionOf<HTMLTextAreaElement | HTMLInputElement>>document.getElementsByClassName('rxmask'); for (let i = 0; i < DOMInputs.length; i++) { const input = DOMInputs[i]; const parser = new Parser({}, input); // Add event input.oninput = () => parser.onInput(); } })();