UNPKG

@zoibana/phonemask

Version:

Phone mask for Russian phone numbers

192 lines (162 loc) 5.64 kB
const DIGIT_RE = /\d/; const FMT_CHARS = new Set(['(', ')', ' ', '-']); class ZoibanaPhonemask { /** * @param {string|HTMLElement} selector — CSS-селектор или сам input-элемент */ constructor(selector) { if (selector instanceof HTMLElement) { this.initEventsOnElement(selector); } else if (typeof selector === 'string') { const runInit = () => { document.querySelectorAll(selector).forEach(el => this.initEventsOnElement(el)); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', runInit); } else { runInit(); } } else { throw new Error('ZoibanaPhonemask: selector must be a string or DOM element'); } } /** Удаляет всё, кроме цифр */ stripNonDigits(str) { return String(str).replace(/\D/g, ''); } /** Проверка на российский номер по первой цифре */ isRussianNumber(digits) { return digits[0] === '7' || digits[0] === '8' || digits[0] === '9'; } /** * Форматирует строку цифр: * — для РФ: +7(XXX) XXX-XX-XX * — для остальных: +[цифры] */ formatPhoneNumber(digits) { let clean = this.stripNonDigits(digits); if (!clean) return ''; if (clean[0] === '9') { clean = '7' + clean; } const russian = this.isRussianNumber(clean); clean = clean.substring(0, russian ? 11 : 16); if (!russian) return '+' + clean; let result = '+7'; if (clean.length > 1) result += '(' + clean.slice(1, 4) + ')'; if (clean.length > 4) result += ' ' + clean.slice(4, 7); if (clean.length > 7) result += '-' + clean.slice(7, 9); if (clean.length > 9) result += '-' + clean.slice(9, 11); return result; } /** * По отформатированной строке находит позицию курсора, * соответствующую заданному количеству цифр до курсора. */ findCursorPos(formatted, digitCount) { if (digitCount === 0) return 0; let count = 0; for (let i = 0; i < formatted.length; i++) { if (DIGIT_RE.test(formatted[i])) { count++; if (count === digitCount) return i + 1; } } return formatted.length; } /** Навешиваем обработчики на конкретный input */ initEventsOnElement(element) { element.addEventListener('keydown', e => this.onKeyDown(e)); element.addEventListener('input', e => this.onInput(e), false); element.addEventListener('paste', e => this.onPaste(e), false); if (element.value) { const digits = this.stripNonDigits(element.value); element.value = digits ? this.formatPhoneNumber(digits) : ''; } } /** Перехватываем вставку, форматируем и сохраняем курсор после вставленных цифр */ onPaste(e) { e.preventDefault(); const input = e.target; const pasted = (e.clipboardData || window.clipboardData).getData('text'); const pastedDigits = this.stripNonDigits(pasted); const raw = input.value; const start = input.selectionStart; const end = input.selectionEnd; const before = this.stripNonDigits(raw.slice(0, start)); const after = this.stripNonDigits(raw.slice(end)); const combined = before + pastedDigits + after; const formatted = combined ? this.formatPhoneNumber(combined) : ''; input.value = formatted; let cursorDigits = (before + pastedDigits).length; if (combined[0] === '9') cursorDigits++; const totalDigits = this.stripNonDigits(formatted).length; const pos = this.findCursorPos(formatted, Math.min(cursorDigits, totalDigits)); input.setSelectionRange(pos, pos); } /** Обработка ввода: формат + сохранение курсора */ onInput(e) { if (!e.isTrusted) return; const input = e.target; if (input.value === '+') return; const prevPos = input.selectionStart; const rawValue = input.value; let digitsCount = 0; for (let i = 0; i < prevPos; i++) { if (DIGIT_RE.test(rawValue[i])) digitsCount++; } const digits = this.stripNonDigits(rawValue); if (!digits) { input.value = ''; return; } // компенсируем: formatPhoneNumber превращает 9… → 79… (+1 цифра) if (digits[0] === '9') digitsCount++; const formatted = this.formatPhoneNumber(digits); input.value = formatted; const newPos = Math.max( this.findCursorPos(formatted, digitsCount), formatted[0] === '+' ? 1 : 0 ); input.setSelectionRange(newPos, newPos); } /** * При попытке удалить служебный символ — * «перескакиваем» через него, не сбрасывая курсор в конец. */ onKeyDown(e) { if (e.key !== 'Backspace' && e.key !== 'Delete') return; const input = e.target; if (input.selectionStart !== input.selectionEnd) return; const pos = input.selectionStart; const val = input.value; const digits = this.stripNonDigits(val); if (e.key === 'Backspace') { if (digits.length <= 1) { e.preventDefault(); input.value = ''; return; } let newPos = pos; while (newPos > 0 && FMT_CHARS.has(val[newPos - 1])) { newPos--; } if (newPos !== pos) { e.preventDefault(); input.setSelectionRange(newPos, newPos); } } if (e.key === 'Delete') { if (digits.length <= 1) return; let newPos = pos; while (newPos < val.length && FMT_CHARS.has(val[newPos])) { newPos++; } if (newPos !== pos) { e.preventDefault(); input.setSelectionRange(newPos, newPos); } } } } export default ZoibanaPhonemask;