simple-phone-mask
Version:
A lightweight and customizable phone number input mask with country flags, search, validation and dark theme.
454 lines (385 loc) • 15.8 kB
JavaScript
import './simple-phone-mask.css';
import { countryData } from './country-data';
/**
* Class representing a phone number mask
* @version 1.0.5
*/
class SimplePhoneMask {
/**
* Create a phone mask
* @param {string} selector - CSS selector for input elements
* @param {Object} options - Configuration options
* @param {string} [options.countryCode="UA"] - Country code (e.g., 'UA', 'US') or phone code (e.g., '+380', '+1')
* @param {string} [options.maskPattern=null] - Custom mask pattern (overrides default country mask)
* @param {boolean} [options.showFlag=true] - Show country flag
* @param {boolean} [options.allowCountrySelect=true] - Allow country selection from dropdown
* @param {boolean} [options.detectIP=false] - Detect country by IP
* @param {boolean} [options.darkTheme=false] - Enable dark theme for dropdown
* @param {boolean} [options.showSearch=true] - Show search field inside dropdown
* @param {boolean} [options.validate=false] - Enable phone validation on blur
* @param {Function} [options.onValidate=null] - Callback: (isValid, value) => void
* @param {string[]} [options.preferredCountries=[]] - Country codes pinned at the top of the dropdown
* @param {string} [options.errorMessage] - Custom error message when phone is incomplete
* @param {string} [options.successMessage] - Custom success message when phone is valid
*/
constructor(selector, options = {}) {
this.selector = selector;
const defaultOptions = {
countryCode: 'UA',
maskPattern: null,
showFlag: true,
allowCountrySelect: true,
detectIP: false,
darkTheme: false,
showSearch: true,
validate: false,
onValidate: null,
preferredCountries: [],
errorMessage: 'Please enter a complete phone number.',
successMessage: '✓ Looks good!',
};
this.options = { ...defaultOptions, ...options };
// Resolve initial country
let country = this._resolveCountry(this.options.countryCode);
if (!country) country = countryData.UA;
this._applyCountry(country);
// Bind methods
this.createMask = this.createMask.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleCountrySelect = this.handleCountrySelect.bind(this);
this.createDropdown = this.createDropdown.bind(this);
this._handleBlurValidation = this._handleBlurValidation.bind(this);
this.initialize();
}
/* ─────────────────────────────────────────
Private helpers
───────────────────────────────────────── */
_resolveCountry(code) {
if (!code) return undefined;
if (code.startsWith('+')) {
return Object.values(countryData).find((c) => c.phoneCode === code);
}
return countryData[code.toUpperCase()];
}
_applyCountry(country) {
this.countryCode = country.phoneCode.replace('+', '');
this.maskPattern = this.options.maskPattern || country.mask;
this.prefixLength = this.countryCode.length + 1; // +1 for "+" char
this.currentCountry = country;
}
_isComplete(input) {
// Count required digits: country code digits + number of _ placeholders in mask
const maskSlots = (this.maskPattern.match(/_/g) || []).length;
const totalDigits = this.countryCode.length + maskSlots;
const enteredDigits = input.value.replace(/\D/g, '').length;
return enteredDigits === totalDigits;
}
_runValidation(input, msgEl) {
const isEmpty = input.value === '+' + this.countryCode || input.value.trim() === '';
const isValid = this._isComplete(input);
input.classList.remove('spm-valid', 'spm-invalid');
if (!isEmpty) {
input.classList.add(isValid ? 'spm-valid' : 'spm-invalid');
}
if (msgEl) {
if (!isEmpty && !isValid) {
msgEl.textContent = this.options.errorMessage;
msgEl.className = 'spm-validation-message';
msgEl.style.display = 'block';
} else if (!isEmpty && isValid) {
msgEl.textContent = this.options.successMessage;
msgEl.className = 'spm-validation-message spm-valid-msg';
msgEl.style.display = 'block';
} else {
msgEl.style.display = 'none';
}
}
if (typeof this.options.onValidate === 'function') {
this.options.onValidate(isValid, input.value);
}
return isValid;
}
_handleBlurValidation(e) {
if (!this.options.validate) return;
const input = e.target;
const msgEl = input._spmMsgEl || null;
this._runValidation(input, msgEl);
}
/* ─────────────────────────────────────────
Cursor helpers
───────────────────────────────────────── */
setCursorPosition(pos, elem, setFocus = false) {
if (setFocus) elem.focus();
if (elem.setSelectionRange) {
elem.setSelectionRange(pos, pos);
} else if (elem.createTextRange) {
const range = elem.createTextRange();
range.collapse(true);
range.moveEnd('character', pos);
range.moveStart('character', pos);
range.select();
}
}
handleClick(e) {
const input = e.target;
if (input.selectionStart < this.prefixLength + 1) {
this.setCursorPosition(this.prefixLength + 1, input, true);
}
}
handleKeyDown(event) {
const input = event.target;
const cursorPosition = input.selectionStart;
if (
(event.key === 'Backspace' || event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
cursorPosition <= this.prefixLength
) {
event.preventDefault();
this.setCursorPosition(this.prefixLength + 1, input, true);
}
const digitsEntered = input.value.replace(/\D/g, '').length - this.countryCode.length;
if (event.key === 'ArrowUp' && digitsEntered > 1) {
event.preventDefault();
}
}
handleSelect(e) {
const input = e.target;
if (input.selectionStart < this.prefixLength + 1) {
this.setCursorPosition(this.prefixLength + 1, input, true);
}
}
/* ─────────────────────────────────────────
Mask
───────────────────────────────────────── */
createMask(event) {
const input = event.target;
const matrix = '+' + this.countryCode + ' ' + this.maskPattern;
let i = 0;
let val = input.value.replace(/\D/g, '');
input.value = matrix.replace(/./g, (a) => {
if (/[_\d]/.test(a) && i < val.length) {
return val.charAt(i++);
} else if (i >= val.length) {
return '';
} else {
return a;
}
});
if (event.type === 'blur') {
if (input.value.length <= this.prefixLength + 1) {
input.value = '+' + this.countryCode;
}
} else {
this.setCursorPosition(input.value.length, input);
}
}
/* ─────────────────────────────────────────
Dropdown
───────────────────────────────────────── */
/**
* Build a single option element
*/
_buildOption(code, country, input, isSelected) {
const option = document.createElement('div');
option.className = 'spm-dropdown-option' + (isSelected ? ' spm-option-selected' : '');
option.dataset.name = country.name.toLowerCase();
option.dataset.code = country.phoneCode;
option.innerHTML = `
<img class="spm-flag-image" src="${country.flag}" alt="${country.name}">
<span class="spm-country-name">${country.name}</span>
<span class="spm-country-code">${country.phoneCode}</span>
`;
option.addEventListener('click', () => this.handleCountrySelect(country, code, input));
return option;
}
createDropdown(input) {
if (!this.options.showFlag) return;
const wrapper = document.createElement('div');
wrapper.className = 'spm-wrapper' + (this.options.darkTheme ? ' spm-dark' : '');
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
input.classList.add('spm-input');
const flagButton = document.createElement('div');
flagButton.className = `spm-flag-button ${
this.options.allowCountrySelect ? 'spm-flag-button--selectable' : 'spm-flag-button--non-selectable'
}`;
flagButton.innerHTML = `<img class="spm-flag-image" src="${this.currentCountry.flag}" alt="${this.currentCountry.name}">`;
wrapper.appendChild(flagButton);
if (!this.options.allowCountrySelect) return;
const dropdown = document.createElement('div');
dropdown.className = 'spm-dropdown';
/* ---- Search field ---- */
if (this.options.showSearch) {
const searchWrapper = document.createElement('div');
searchWrapper.className = 'spm-search-wrapper';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'spm-search-input';
searchInput.placeholder = 'Search country…';
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase().trim();
list.querySelectorAll('.spm-dropdown-option').forEach((opt) => {
const matchName = opt.dataset.name.includes(query);
const matchCode = opt.dataset.code.includes(query);
opt.style.display = matchName || matchCode ? '' : 'none';
});
const noResults = list.querySelector('.spm-no-results');
const anyVisible = [...list.querySelectorAll('.spm-dropdown-option')].some(
(o) => o.style.display !== 'none'
);
if (noResults) noResults.style.display = anyVisible ? 'none' : '';
});
searchWrapper.appendChild(searchInput);
dropdown.appendChild(searchWrapper);
// Focus search when dropdown opens
flagButton.addEventListener('click', () => {
if (dropdown.style.display === 'block') {
setTimeout(() => searchInput.focus(), 0);
}
});
}
/* ---- Options list ---- */
const list = document.createElement('div');
list.className = 'spm-options-list';
const preferred = (this.options.preferredCountries || []).map((c) => c.toUpperCase());
/* Preferred countries first */
if (preferred.length > 0) {
preferred.forEach((code) => {
const country = countryData[code];
if (!country) return;
const isSelected = country.phoneCode === this.currentCountry.phoneCode;
list.appendChild(this._buildOption(code, country, input, isSelected));
});
const divider = document.createElement('hr');
divider.className = 'spm-divider';
list.appendChild(divider);
}
/* All countries */
Object.entries(countryData).forEach(([code, country]) => {
if (preferred.includes(code)) return; // already added above
const isSelected = country.phoneCode === this.currentCountry.phoneCode;
list.appendChild(this._buildOption(code, country, input, isSelected));
});
/* No results message */
const noResults = document.createElement('div');
noResults.className = 'spm-no-results';
noResults.textContent = 'No countries found.';
noResults.style.display = 'none';
list.appendChild(noResults);
dropdown.appendChild(list);
wrapper.appendChild(dropdown);
/* ---- Toggle open/close ---- */
flagButton.addEventListener('click', () => {
const isOpen = dropdown.style.display === 'block';
dropdown.style.display = isOpen ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
dropdown.style.display = 'none';
}
});
}
handleCountrySelect(country, code, input) {
this._applyCountry(country);
// Update flag
const flagButton = input.parentNode.querySelector('.spm-flag-button');
flagButton.innerHTML = `<img class="spm-flag-image" src="${country.flag}" alt="${country.name}">`;
// Update selected highlight
const list = input.parentNode.querySelector('.spm-options-list');
if (list) {
list.querySelectorAll('.spm-dropdown-option').forEach((opt) => {
opt.classList.toggle('spm-option-selected', opt.dataset.code === country.phoneCode);
});
}
// Close dropdown
const dropdown = input.parentNode.querySelector('.spm-dropdown');
if (dropdown) dropdown.style.display = 'none';
// Reset search
const searchInput = input.parentNode.querySelector('.spm-search-input');
if (searchInput) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
}
input.value = '+' + this.countryCode;
this.createMask({ type: 'input', target: input });
input.focus();
}
/* ─────────────────────────────────────────
Init / Destroy
───────────────────────────────────────── */
async initialize() {
if (this.options.detectIP) {
try {
const code = await this.detectCountryByIP();
this.options.countryCode = code;
const country = countryData[code];
if (country) this._applyCountry(country);
} catch (error) {
console.error('Failed to detect country by IP:', error);
}
}
const inputs = document.querySelectorAll(this.selector);
inputs.forEach((input) => {
if (this.options.showFlag) {
this.createDropdown(input);
}
/* Validation message element — inserted AFTER the wrapper (or input if no flag) */
if (this.options.validate) {
const msgEl = document.createElement('span');
msgEl.className = 'spm-validation-message';
// input may now be inside a wrapper — insert after the outermost container
const container = input.closest('.spm-wrapper') || input;
container.parentNode.insertBefore(msgEl, container.nextSibling);
input._spmMsgEl = msgEl;
}
input.addEventListener('input', this.createMask);
input.addEventListener('focus', this.createMask);
input.addEventListener('blur', this.createMask);
input.addEventListener('blur', this._handleBlurValidation);
input.addEventListener('click', this.handleClick);
input.addEventListener('keydown', this.handleKeyDown);
input.addEventListener('select', this.handleSelect);
input.value = '+' + this.countryCode;
this.createMask({ type: 'input', target: input });
});
}
async detectCountryByIP() {
try {
const response = await fetch('https://ipapi.co/json/', { cache: 'no-store' });
const data = await response.json();
return data.country_code;
} catch (error) {
console.error('Error detecting country by IP:', error);
return this.options.countryCode;
}
}
/**
* Validate current value programmatically
* @param {string} [selector] - Optional selector override
* @returns {boolean}
*/
validate(selector) {
const sel = selector || this.selector;
const inputs = document.querySelectorAll(sel);
let allValid = true;
inputs.forEach((input) => {
const valid = this._runValidation(input, input._spmMsgEl || null);
if (!valid) allValid = false;
});
return allValid;
}
destroy() {
const inputs = document.querySelectorAll(this.selector);
inputs.forEach((input) => {
input.removeEventListener('input', this.createMask);
input.removeEventListener('focus', this.createMask);
input.removeEventListener('blur', this.createMask);
input.removeEventListener('blur', this._handleBlurValidation);
input.removeEventListener('click', this.handleClick);
input.removeEventListener('keydown', this.handleKeyDown);
input.removeEventListener('select', this.handleSelect);
});
}
}
export default SimplePhoneMask;