@zoibana/phonemask
Version:
Phone mask for Russian phone numbers
192 lines (162 loc) • 5.64 kB
JavaScript
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;