PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-forms / src / modules / field-phone / view.js
jetpack / jetpack_vendor / automattic / jetpack-forms / src / modules / field-phone Last commit date
view.js 2 months ago
view.js
345 lines
1 import {
2 store,
3 getContext,
4 getConfig,
5 getElement,
6 withSyncEvent as originalWithSyncEvent,
7 } from '@wordpress/interactivity';
8 import parsePhoneNumber, { AsYouType } from 'libphonenumber-js/min/es6';
9 import { countries } from '../../blocks/field-telephone/country-list.js';
10 import { isEmptyValue } from '../../contact-form/js/validate-helper.js';
11 const NAMESPACE = 'jetpack/form';
12
13 const withSyncEvent =
14 originalWithSyncEvent ||
15 ( cb =>
16 ( ...args ) =>
17 cb( ...args ) );
18
19 const asYouTypes = {};
20 const phoneInputRefs = {};
21 const searchInputRefs = {};
22 const optionsListRefs = {};
23
24 /**
25 * Ensures the phone field is fully initialized. Serves as both the primary
26 * initialization path (called from data-wp-init) and a safety net for event
27 * handlers in case the init callback was missed — which can happen when the
28 * module loads after DOMContentLoaded and the Interactivity API has already
29 * processed the DOM.
30 *
31 * @param {string} fieldId - The field ID to initialize.
32 * @return {boolean} Whether the field is initialized and ready.
33 */
34 const ensureInitialized = fieldId => {
35 if ( asYouTypes[ fieldId ] ) {
36 return true;
37 }
38
39 const context = getContext();
40 if ( ! context.showCountrySelector ) {
41 return true;
42 }
43
44 // Resolve refs via DOM query if register callbacks haven't fired.
45 const { ref } = getElement();
46 const wrapper = ref.closest( '.jetpack-field__input-phone-wrapper' );
47 if ( ! wrapper ) {
48 return false;
49 }
50
51 if ( ! phoneInputRefs[ fieldId ] ) {
52 phoneInputRefs[ fieldId ] = wrapper.querySelector( '.jetpack-field__input-element' );
53 }
54 if ( ! searchInputRefs[ fieldId ] ) {
55 searchInputRefs[ fieldId ] = wrapper.querySelector( '.jetpack-combobox-search' );
56 }
57 if ( ! optionsListRefs[ fieldId ] ) {
58 optionsListRefs[ fieldId ] = wrapper.querySelector( '.jetpack-combobox-options' );
59 }
60
61 if (
62 ! phoneInputRefs[ fieldId ] ||
63 ! searchInputRefs[ fieldId ] ||
64 ! optionsListRefs[ fieldId ]
65 ) {
66 return false;
67 }
68
69 const config = getConfig( 'jetpack/field-phone' );
70 context.allCountries = countries.map( country => ( {
71 ...country,
72 country: config?.i18n?.countryNames?.[ country.code ] || country.country,
73 selected: country.code === context.defaultCountry,
74 } ) );
75 context.filteredCountries = [ ...context.allCountries ];
76 context.selectedCountry = context.filteredCountries.find(
77 country => country.code === context.defaultCountry
78 );
79 asYouTypes[ fieldId ] = new AsYouType( context.defaultCountry );
80 return true;
81 };
82
83 /**
84 * Sets flag text on an element by modifying existing text node data instead of
85 * replacing textContent. This avoids triggering wp-emoji's MutationObserver
86 * (which only watches childList, not characterData) that converts emoji to SVG.
87 *
88 * @param {HTMLElement} element - The element to set flag text on.
89 * @param {string} flag - The flag emoji to display.
90 */
91 const setFlagText = ( element, flag ) => {
92 const text = flag || '';
93 if ( element.firstChild?.nodeType === 3 ) {
94 element.firstChild.data = text;
95 } else {
96 element.textContent = text;
97 }
98 };
99 const updateSelection = selectedCountry => {
100 const context = getContext();
101 context.phoneCountryCode = selectedCountry.code;
102 context.countryPrefix = selectedCountry.value;
103 context.fullPhoneNumber = context.countryPrefix + ' ' + context.phoneNumber;
104 asYouTypes[ context.fieldId ] = new AsYouType( context.phoneCountryCode );
105 context.filteredCountries = context.filteredCountries.map( country => ( {
106 ...country,
107 selected: country.code === selectedCountry.code,
108 } ) );
109 context.allCountries = context.allCountries.map( country => ( {
110 ...country,
111 selected: country.code === selectedCountry.code,
112 } ) );
113 };
114
115 const { actions } = store( NAMESPACE, {
116 state: {
117 validators: {
118 phone: ( value, isRequired ) => {
119 const context = getContext();
120
121 if ( isEmptyValue( context.phoneNumber ) && isRequired ) {
122 // this is not triggering any error, but then no other input does either
123 return 'is_required';
124 }
125 if ( ! isRequired && isEmptyValue( context.phoneNumber ) ) {
126 // No need to validate anything.
127 return 'yes';
128 }
129
130 // from this point on, we discard the value as we
131 // use our internal full phone number state getter:
132 value = context.fullPhoneNumber;
133 if (
134 context.showCountrySelector ||
135 value.indexOf( '+' ) === 0 ||
136 value.indexOf( '00' ) === 0
137 ) {
138 const internationalNumber = parsePhoneNumber( value );
139 if ( ! internationalNumber || ! internationalNumber.isValid() ) {
140 return 'invalid_phone';
141 }
142 }
143
144 // if no country selector or value starting with +, use legacy regex check
145 if ( ! /^\+?[0-9\s\-()]+$/.test( value ) ) {
146 return 'invalid_phone';
147 }
148
149 return 'yes';
150 },
151 },
152 },
153 actions: {
154 phoneResetHandler() {
155 const context = getContext();
156 context.phoneCountryCode = context.defaultCountry;
157 context.phoneNumber = '';
158 },
159 phoneNumberInputHandler( event ) {
160 const context = getContext();
161 const fieldId = context.fieldId;
162 const value = event.target.value;
163 if ( ! context.showCountrySelector ) {
164 context.phoneNumber = context.fullPhoneNumber = value;
165 return;
166 }
167 if ( ! ensureInitialized( fieldId ) ) {
168 return;
169 }
170 const groomedValue = value.indexOf( '00' ) === 0 ? '+' + value.slice( 2 ) : value;
171
172 asYouTypes[ fieldId ].reset();
173 asYouTypes[ fieldId ].input( groomedValue );
174 if ( asYouTypes[ fieldId ].getCountry() ) {
175 const countryCode = asYouTypes[ fieldId ].getCountry();
176 context.phoneNumber = asYouTypes[ fieldId ].getNationalNumber();
177 context.selectedCountry = context.allCountries.find(
178 country => country.code === countryCode
179 );
180 updateSelection( context.selectedCountry );
181 } else {
182 context.phoneNumber = value;
183 }
184
185 actions.updateField( fieldId, value );
186 },
187 phoneCountryChangeHandler() {
188 const context = getContext();
189 if ( ! ensureInitialized( context.fieldId ) ) {
190 return;
191 }
192 // this context.filtered is from the template iterator
193 context.selectedCountry = { ...context.filtered };
194 updateSelection( context.selectedCountry );
195 context.comboboxOpen = false;
196 phoneInputRefs[ context.fieldId ]?.focus?.();
197 },
198 phoneComboboxInputHandler( event ) {
199 const context = getContext();
200 if ( ! ensureInitialized( context.fieldId ) ) {
201 return;
202 }
203 const searchTerm = event.target.value.toLowerCase();
204 context.filteredCountries = context.allCountries.filter(
205 country =>
206 country.country.toLowerCase().includes( searchTerm ) ||
207 country.code.toLowerCase().includes( searchTerm ) ||
208 country.value.includes( searchTerm )
209 );
210 optionsListRefs[ context.fieldId ].scrollTo?.( { top: 0, behavior: 'instant' } );
211 },
212 phoneComboboxKeydownHandler: withSyncEvent( event => {
213 const context = getContext();
214 if ( ! ensureInitialized( context.fieldId ) ) {
215 return;
216 }
217 if ( event.key === 'Escape' ) {
218 context.comboboxOpen = false;
219 } else if ( event.key === 'Enter' ) {
220 event.preventDefault();
221 // Select either the currently selected country or the first filtered option if available
222 if ( context.filteredCountries.length > 0 ) {
223 const selectedCountry =
224 context.filteredCountries.find( country => country.selected ) ||
225 context.filteredCountries[ 0 ];
226 context.selectedCountry = selectedCountry;
227 updateSelection( context.selectedCountry );
228 context.comboboxOpen = false;
229 // Focus on the ref input
230 phoneInputRefs[ context.fieldId ]?.focus?.();
231 }
232 } else if ( event.key === 'ArrowDown' ) {
233 event.preventDefault();
234 if ( context.filteredCountries.length > 0 ) {
235 // Find index of currently selected country in filtered list
236 const selectedIndex = context.filteredCountries.findIndex( country => country.selected );
237
238 // If there's a next country in filtered list, select it, otherwise wrap to first
239 const nextIndex =
240 selectedIndex === context.filteredCountries.length - 1 ? 0 : selectedIndex + 1;
241 context.selectedCountry = context.filteredCountries[ nextIndex ];
242 updateSelection( context.selectedCountry );
243 setTimeout( () => {
244 // Find and scroll the newly selected option into view
245 const selectedOption = optionsListRefs[ context.fieldId ].querySelector(
246 '.jetpack-combobox-option-selected'
247 );
248 selectedOption?.scrollIntoView?.( {
249 block: 'nearest',
250 container: 'nearest',
251 behavior: 'instant',
252 } );
253 }, 0 );
254 }
255 } else if ( event.key === 'ArrowUp' ) {
256 event.preventDefault();
257 if ( context.filteredCountries.length > 0 ) {
258 // Find index of currently selected country in filtered list
259 const selectedIndex = context.filteredCountries.findIndex( country => country.selected );
260
261 // If there's a previous country in filtered list, select it, otherwise wrap to last
262 const prevIndex =
263 selectedIndex <= 0 ? context.filteredCountries.length - 1 : selectedIndex - 1;
264 context.selectedCountry = context.filteredCountries[ prevIndex ];
265 updateSelection( context.selectedCountry );
266 setTimeout( () => {
267 // Find and scroll the newly selected option into view
268 const selectedOption = optionsListRefs[ context.fieldId ].querySelector(
269 '.jetpack-combobox-option-selected'
270 );
271 selectedOption?.scrollIntoView?.( {
272 block: 'nearest',
273 container: 'nearest',
274 behavior: 'instant',
275 } );
276 }, 0 );
277 }
278 }
279 } ),
280 phoneNumberFocusHandler() {
281 const context = getContext();
282 context.comboboxOpen = false;
283 },
284 phoneComboboxToggle() {
285 const context = getContext();
286 if ( ! ensureInitialized( context.fieldId ) ) {
287 return;
288 }
289 context.comboboxOpen = ! context.comboboxOpen;
290 if ( context.comboboxOpen ) {
291 setTimeout( () => {
292 searchInputRefs[ context.fieldId ]?.focus?.();
293 optionsListRefs[ context.fieldId ]
294 .querySelector( '.jetpack-combobox-option-selected' )
295 ?.scrollIntoView?.( { block: 'nearest', container: 'nearest' } );
296 }, 0 );
297 }
298 },
299 phoneComboboxDocumentClickHandler( event ) {
300 const { ref } = getElement();
301 if ( ref.contains( event.target ) ) {
302 return;
303 }
304 const context = getContext();
305 context.comboboxOpen = false;
306 },
307 },
308 callbacks: {
309 /**
310 * Sets flag text by modifying existing text node data (nodeType 3 = TEXT_NODE)
311 * instead of replacing textContent, to avoid triggering wp-emoji's MutationObserver
312 * which only watches childList (not characterData) and converts emoji to SVG images.
313 */
314 updateSelectedFlag() {
315 const { ref } = getElement();
316 const context = getContext();
317 setFlagText( ref, context.selectedCountry?.flag );
318 },
319 updateOptionFlag() {
320 const { ref } = getElement();
321 const context = getContext();
322 setFlagText( ref, context.filtered?.flag );
323 },
324 registerPhoneInput() {
325 const element = getElement().ref;
326 const context = getContext();
327 phoneInputRefs[ context.fieldId ] = element;
328 },
329 registerPhoneComboboxSearchInput() {
330 const element = getElement().ref;
331 const context = getContext();
332 searchInputRefs[ context.fieldId ] = element;
333 },
334 registerPhoneComboboxOptionsList() {
335 const element = getElement().ref;
336 const context = getContext();
337 optionsListRefs[ context.fieldId ] = element;
338 },
339 initializePhoneFieldCustomComboBox() {
340 const context = getContext();
341 ensureInitialized( context.fieldId );
342 },
343 },
344 } );
345