UNPKG

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
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;