1/** 2 * Class representing a phone number mask 3 */ 4class SimplePhoneMask { 5 /** 6 * Create a phone mask 7 * @param {string} selector - CSS selector for input elements 8 * @param {Object} options - Configuration options 9 * @param {string} [options.countryCode="RU"] - Country code (e.g., 'UA', 'US', 'GB') or phone code (e.g., '+380', '+1', '+44') 10 * @param {string} [options.maskPattern=null] - Custom mask pattern (overrides default country mask) 11 * @param {boolean} [options.showFlag=true] - Show country flag 12 * @param {boolean} [options.allowCountrySelect=true] - Allow country selection from dropdown 13 * @param {boolean} [options.detectIP=false] - Detect country by IP 14 */ 15 constructor(selector, options = {}) { 16 this.selector = selector; 17 18 // Normalize options with defaults 19 const defaultOptions = { 20 countryCode: 'RU', 21 maskPattern: null, 22 showFlag: true, 23 allowCountrySelect: true, 24 detectIP: false, 25 }; 26 27 this.options = { ...defaultOptions, ...options }; 28 29 // Get country data 30 let country; 31 if (this.options.countryCode.startsWith('+')) { 32 // Find country by phone code 33 const phoneCode = this.options.countryCode; 34 country = Object.values(countryData).find((c) => c.phoneCode === phoneCode); 35 } else { 36 // Find country by country code 37 country = countryData[this.options.countryCode.toUpperCase()]; 38 } 39 40 if (!country) { 41 // console.error(`Invalid country code: ${this.options.countryCode}`); 42 country = countryData.RU; // Default to Ukraine 43 } 44 45 this.countryCode = country.phoneCode.replace('+', ''); 46 this.maskPattern = this.options.maskPattern || country.mask; 47 this.prefixLength = this.countryCode.length + 1; // +1 for + symbol 48 this.currentCountry = country; 49 50 // Bind methods to preserve context 51 this.createMask = this.createMask.bind(this); 52 this.handleClick = this.handleClick.bind(this); 53 this.handleKeyDown = this.handleKeyDown.bind(this); 54 this.handleSelect = this.handleSelect.bind(this); 55 this.handleCountrySelect = this.handleCountrySelect.bind(this); 56 this.createDropdown = this.createDropdown.bind(this); 57 58 this.initialize(); 59 } 60 61 /** 62 * Set cursor position in input element 63 * @param {number} pos - Cursor position 64 * @param {HTMLElement} elem - Input element 65 * @param {boolean} [setFocus=false] - Whether to set focus on element 66 */ 67 setCursorPosition(pos, elem, setFocus = false) { 68 if (setFocus) { 69 elem.focus(); 70 } 71 72 if (elem.setSelectionRange) { 73 elem.setSelectionRange(pos, pos); 74 } else if (elem.createTextRange) { 75 let range = elem.createTextRange(); 76 range.collapse(true); 77 range.moveEnd('character', pos); 78 range.moveStart('character', pos); 79 range.select(); 80 } 81 } 82 83 /** 84 * Handle cursor position on click 85 * @param {Event} e - Click event 86 */ 87 handleClick(e) { 88 const input = e.target; 89 if (input.selectionStart < this.prefixLength + 1) { 90 // Set focus to true only for click events 91 this.setCursorPosition(this.prefixLength + 1, input, true); 92 } 93 } 94 95 /** 96 * Handle keyboard navigation 97 * @param {KeyboardEvent} event - Keyboard event 98 */ 99 handleKeyDown(event) { 100 const input = event.target; 101 const cursorPosition = input.selectionStart; 102 103 // console.log( cursorPosition ) 104 105 if ( 106 (event.key === 'Backspace' || event.key === 'ArrowLeft' || event.key === 'ArrowUp') && 107 cursorPosition <= this.prefixLength 108 ) { 109 event.preventDefault(); 110 // Set focus to true for keyboard navigation 111 this.setCursorPosition(this.prefixLength + 1, input, true); 112 } 113 114 // Prevent cursor from moving up when more than one digit is entered 115 const digitsEntered = input.value.replace(/\D/g, '').length - this.countryCode.length; 116 if (event.key === 'ArrowUp' && digitsEntered > 1) { 117 event.preventDefault(); 118 } 119 120 // Ensure the mask is applied correctly and the cursor stays within the allowed range 121 // This is crucial for maintaining the correct mask format and preventing the cursor from moving outside the allowed range 122 } 123 124 /** 125 * Handle text selection 126 * @param {Event} e - Select event 127 */ 128 handleSelect(e) { 129 const input = e.target; 130 if (input.selectionStart < this.prefixLength + 1) { 131 // Set focus to true only for select events 132 this.setCursorPosition(this.prefixLength + 1, input, true); 133 } 134 } 135
136 /** 137 * Create mask for input value 138 * @param {Event} event - Input event 139 */ 140 createMask(event) { 141 const input = event.target; 142 const matrix = '+' + this.countryCode + ' ' + this.maskPattern; 143 let i = 0; 144 const def = matrix.replace(/\D/g, ''); 145 let val = input.value.replace(/\D/g, ''); 146 147 // Remove temporary protection of country code for debugging 148 // if (!input.value.startsWith("+" + this.countryCode)) { 149 // val = def; 150 // } 151 152 // еÑли вÑоÑой введÑннÑй Ñимвол 8 или 9, а пеÑвÑй 8, Ñо ÑдалиÑÑ Ð¿ÐµÑвÑй Ñимвол 153 if( this.countryCode == 7 && val.length == 3 && val.charAt(1) == 8 && (val.charAt(2) == 8 || val.charAt(2) == 9) ) 154 val = val.slice(0, 1) + val.slice(2); 155 156 input.value = matrix.replace(/./g, function (a) { 157 if (/[_\d]/.test(a) && i < val.length) { 158 return val.charAt(i++); 159 } else if (i >= val.length) { 160 return ''; 161 } else { 162 return a; 163 } 164 }); 165 166 if (event.type === 'blur') { 167 if (input.value.length <= this.prefixLength + 1) { 168 input.value = '+' + this.countryCode; 169 } 170 } else { 171 this.setCursorPosition(input.value.length, input); 172 } 173 } 174 175 /** 176 * Create country selector dropdown 177 * @param {HTMLElement} input - Input element 178 */ 179 createDropdown(input) { 180 if (!this.options.showFlag) return; 181 182 const wrapper = document.createElement('div'); 183 wrapper.className = 'spm-wrapper'; 184 input.parentNode.insertBefore(wrapper, input); 185 wrapper.appendChild(input); 186 input.className += ' spm-input'; 187 188 const flagButton = document.createElement('div'); 189 flagButton.className = `spm-flag-button ${ 190 this.options.allowCountrySelect ? 'spm-flag-button--selectable' : 'spm-flag-button--non-selectable' 191 }`; 192 193 flagButton.innerHTML = `<img class="spm-flag-image" src="${this.currentCountry.flag}" alt="${this.currentCountry.name}"><svg class="spm-arrow-icon" width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2"/></svg>`; 194 wrapper.appendChild(flagButton); 195 196 if (this.options.allowCountrySelect) { 197 const dropdown = document.createElement('div'); 198 dropdown.className = 'spm-dropdown'; 199 200 Object.entries(countryData).forEach(([code, country]) => { 201 const option = document.createElement('div'); 202 option.className = 'spm-dropdown-option'; 203 204 option.innerHTML = ` 205 <img class="spm-flag-image" src="${country.flag}" alt="${country.name}"> 206 <span class="spm-country-name">${country.name}</span> 207 <span class="spm-country-code">${country.phoneCode}</span> 208 `; 209 210 option.addEventListener('click', () => this.handleCountrySelect(country, code, input)); 211 dropdown.style.display = 'none'; 212 dropdown.appendChild(option); 213 }); 214 215 wrapper.appendChild(dropdown); 216 217 flagButton.addEventListener('click', () => { 218 dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; 219 }); 220 221 document.addEventListener('click', (e) => { 222 if (!wrapper.contains(e.target)) { 223 dropdown.style.display = 'none'; 224 } 225 }); 226 } 227 } 228 229 /** 230 * Handle country selection from dropdown 231 * @param {Object} country - Country data object 232 * @param {string} code - Country code 233 * @param {HTMLElement} input - Input element 234 */ 235 handleCountrySelect(country, code, input) { 236 this.countryCode = country.phoneCode.replace('+', ''); 237 this.maskPattern = this.options.maskPattern || country.mask; 238 this.prefixLength = this.countryCode.length + 1; 239 this.currentCountry = country; 240 241 const flagButton = input.parentNode.querySelector('.spm-flag-button'); 242 flagButton.innerHTML = `<img class="spm-flag-image" src="${country.flag}" alt="${country.name}"><svg class="spm-arrow-icon" width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2"/></svg>`; 243 244 const dropdown = input.parentNode.querySelector('.spm-dropdown'); 245 dropdown.style.display = 'none'; 246 247 input.value = '+' + this.countryCode; 248 249 this.createMask({ 250 type: 'input', 251 target: input, 252 }); 253 } 254 255 /** 256 * Initialize mask for all matching inputs 257 */ 258 async initialize() { 259 if (this.options.detectIP) { 260 try { 261 const countryCode = await this.detectCountryByIP(); 262 this.options.countryCode = countryCode; 263 264 // Update country data after IP detection 265 const country = countryData[countryCode]; 266 if (country) { 267 this.countryCode = country.phoneCode.replace('+', ''); 268 this.maskPattern = this.options.maskPattern || country.mask; 269 this.prefixLength = this.countryCode.length + 1; 270 this.currentCountry = country; 271 } 272 } catch (error) { 273 console.error('Failed to detect country by IP:', error); 274 } 275 } 276 277 // Initialize inputs 278 let inputs = document.querySelectorAll(this.selector); 279
280 inputs.forEach((input) => { 281 if (this.options.showFlag) { 282 this.createDropdown(input); 283 } 284 285 input.addEventListener('input', this.createMask); 286 input.addEventListener('focus', this.createMask); 287 input.addEventListener('blur', this.createMask); 288 input.addEventListener('click', this.handleClick); 289 input.addEventListener('keydown', this.handleKeyDown); 290 input.addEventListener('select', this.handleSelect); 291 292 input.value = '+' + this.countryCode; 293 // Remove focus trigger 294 this.createMask({ type: 'input', target: input }); 295 }); 296 } 297 298 /** 299 * Detect country by IP 300 * @returns {Promise<string>} - Country code 301 */ 302 async detectCountryByIP() { 303 try { 304 const response = await fetch('https://ipapi.co/json/', { 305 cache: 'no-store', // Disable caching 306 }); 307 const data = await response.json(); 308 return data.country_code; 309 } catch (error) { 310 console.error('Error detecting country by IP:', error); 311 return this.options.countryCode; 312 } 313 } 314 315 /** 316 * Destroy mask and remove all event listeners 317 */ 318 destroy() { 319 let inputs = document.querySelectorAll(this.selector); 320 321 inputs.forEach((input) => { 322 input.removeEventListener('input', this.createMask); 323 input.removeEventListener('focus', this.createMask); 324 input.removeEventListener('blur', this.createMask); 325 input.removeEventListener('click', this.handleClick); 326 input.removeEventListener('keydown', this.handleKeyDown); 327 input.removeEventListener('select', this.handleSelect); 328 }); 329 } 330}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.