PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / modules / field-phone / view.js

view.js in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-forms/src/modules/field-phone/view.js

372 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 /**
116 * Revalidate the field after the visitor commits to a country.
117 *
118 * `fullPhoneNumber` is the only value `validators.phone` judges, so without this a number that was
119 * invalid for the previous country keeps its stale `invalid_phone` after the visitor picks the
120 * country that makes it valid, and the error blocks submission until the input is touched again.
121 *
122 * Only the paths that commit call this. `updateSelection()` also runs while the visitor merely
123 * arrows through the list, and revalidating there would reset `showFieldError` on every keypress,
124 * hiding an error that is still true of the number they have typed.
125 */
126 const revalidateAfterCountryCommit = () => {
127 const context = getContext();
128
129 actions.updateField(
130 context.fieldId,
131 context.phoneNumber,
132 context.fields?.[ context.fieldId ]?.showFieldError ?? false
133 );
134 };
135
136 const { actions } = store( NAMESPACE, {
137 state: {
138 validators: {
139 phone: ( value, isRequired ) => {
140 // Defaults matter here: this validator also runs from `registerField()`, which is
141 // scoped to the field wrapper rather than to the phone wrapper that provides these
142 // keys, so during registration they are all undefined. See the scope contract on
143 // `validate()` in ../form/view.js.
144 const {
145 phoneNumber = '',
146 fullPhoneNumber = '',
147 showCountrySelector = false,
148 } = getContext();
149
150 if ( isEmptyValue( phoneNumber ) && isRequired ) {
151 // this is not triggering any error, but then no other input does either
152 return 'is_required';
153 }
154 if ( ! isRequired && isEmptyValue( phoneNumber ) ) {
155 // No need to validate anything.
156 return 'yes';
157 }
158
159 // from this point on, we discard the value as we
160 // use our internal full phone number state getter:
161 value = fullPhoneNumber;
162 if ( showCountrySelector || value.indexOf( '+' ) === 0 || value.indexOf( '00' ) === 0 ) {
163 const internationalNumber = parsePhoneNumber( value );
164 if ( ! internationalNumber || ! internationalNumber.isValid() ) {
165 return 'invalid_phone';
166 }
167 }
168
169 // if no country selector or value starting with +, use legacy regex check
170 if ( ! /^\+?[0-9\s\-()]+$/.test( value ) ) {
171 return 'invalid_phone';
172 }
173
174 return 'yes';
175 },
176 },
177 },
178 actions: {
179 phoneResetHandler() {
180 const context = getContext();
181 context.phoneCountryCode = context.defaultCountry;
182 context.phoneNumber = '';
183 },
184 phoneNumberInputHandler( event ) {
185 const context = getContext();
186 const fieldId = context.fieldId;
187 const value = event.target.value;
188 if ( ! context.showCountrySelector ) {
189 context.phoneNumber = context.fullPhoneNumber = value;
190 return;
191 }
192 if ( ! ensureInitialized( fieldId ) ) {
193 return;
194 }
195 const groomedValue = value.indexOf( '00' ) === 0 ? '+' + value.slice( 2 ) : value;
196
197 asYouTypes[ fieldId ].reset();
198 asYouTypes[ fieldId ].input( groomedValue );
199 if ( asYouTypes[ fieldId ].getCountry() ) {
200 const countryCode = asYouTypes[ fieldId ].getCountry();
201 context.phoneNumber = asYouTypes[ fieldId ].getNationalNumber();
202 context.selectedCountry = context.allCountries.find(
203 country => country.code === countryCode
204 );
205 updateSelection( context.selectedCountry );
206 } else {
207 context.phoneNumber = value;
208 }
209
210 actions.updateField( fieldId, value );
211 },
212 phoneCountryChangeHandler() {
213 const context = getContext();
214 if ( ! ensureInitialized( context.fieldId ) ) {
215 return;
216 }
217 // this context.filtered is from the template iterator
218 context.selectedCountry = { ...context.filtered };
219 updateSelection( context.selectedCountry );
220 revalidateAfterCountryCommit();
221 context.comboboxOpen = false;
222 phoneInputRefs[ context.fieldId ]?.focus?.();
223 },
224 phoneComboboxInputHandler( event ) {
225 const context = getContext();
226 if ( ! ensureInitialized( context.fieldId ) ) {
227 return;
228 }
229 const searchTerm = event.target.value.toLowerCase();
230 context.filteredCountries = context.allCountries.filter(
231 country =>
232 country.country.toLowerCase().includes( searchTerm ) ||
233 country.code.toLowerCase().includes( searchTerm ) ||
234 country.value.includes( searchTerm )
235 );
236 optionsListRefs[ context.fieldId ].scrollTo?.( { top: 0, behavior: 'instant' } );
237 },
238 phoneComboboxKeydownHandler: withSyncEvent( event => {
239 const context = getContext();
240 if ( ! ensureInitialized( context.fieldId ) ) {
241 return;
242 }
243 if ( event.key === 'Escape' ) {
244 context.comboboxOpen = false;
245 } else if ( event.key === 'Enter' ) {
246 event.preventDefault();
247 // Select either the currently selected country or the first filtered option if available
248 if ( context.filteredCountries.length > 0 ) {
249 const selectedCountry =
250 context.filteredCountries.find( country => country.selected ) ||
251 context.filteredCountries[ 0 ];
252 context.selectedCountry = selectedCountry;
253 updateSelection( context.selectedCountry );
254 revalidateAfterCountryCommit();
255 context.comboboxOpen = false;
256 // Focus on the ref input
257 phoneInputRefs[ context.fieldId ]?.focus?.();
258 }
259 } else if ( event.key === 'ArrowDown' ) {
260 event.preventDefault();
261 if ( context.filteredCountries.length > 0 ) {
262 // Find index of currently selected country in filtered list
263 const selectedIndex = context.filteredCountries.findIndex( country => country.selected );
264
265 // If there's a next country in filtered list, select it, otherwise wrap to first
266 const nextIndex =
267 selectedIndex === context.filteredCountries.length - 1 ? 0 : selectedIndex + 1;
268 context.selectedCountry = context.filteredCountries[ nextIndex ];
269 updateSelection( context.selectedCountry );
270 setTimeout( () => {
271 // Find and scroll the newly selected option into view
272 const selectedOption = optionsListRefs[ context.fieldId ].querySelector(
273 '.jetpack-combobox-option-selected'
274 );
275 selectedOption?.scrollIntoView?.( {
276 block: 'nearest',
277 container: 'nearest',
278 behavior: 'instant',
279 } );
280 }, 0 );
281 }
282 } else if ( event.key === 'ArrowUp' ) {
283 event.preventDefault();
284 if ( context.filteredCountries.length > 0 ) {
285 // Find index of currently selected country in filtered list
286 const selectedIndex = context.filteredCountries.findIndex( country => country.selected );
287
288 // If there's a previous country in filtered list, select it, otherwise wrap to last
289 const prevIndex =
290 selectedIndex <= 0 ? context.filteredCountries.length - 1 : selectedIndex - 1;
291 context.selectedCountry = context.filteredCountries[ prevIndex ];
292 updateSelection( context.selectedCountry );
293 setTimeout( () => {
294 // Find and scroll the newly selected option into view
295 const selectedOption = optionsListRefs[ context.fieldId ].querySelector(
296 '.jetpack-combobox-option-selected'
297 );
298 selectedOption?.scrollIntoView?.( {
299 block: 'nearest',
300 container: 'nearest',
301 behavior: 'instant',
302 } );
303 }, 0 );
304 }
305 }
306 } ),
307 phoneNumberFocusHandler() {
308 const context = getContext();
309 context.comboboxOpen = false;
310 },
311 phoneComboboxToggle() {
312 const context = getContext();
313 if ( ! ensureInitialized( context.fieldId ) ) {
314 return;
315 }
316 context.comboboxOpen = ! context.comboboxOpen;
317 if ( context.comboboxOpen ) {
318 setTimeout( () => {
319 searchInputRefs[ context.fieldId ]?.focus?.();
320 optionsListRefs[ context.fieldId ]
321 .querySelector( '.jetpack-combobox-option-selected' )
322 ?.scrollIntoView?.( { block: 'nearest', container: 'nearest' } );
323 }, 0 );
324 }
325 },
326 phoneComboboxDocumentClickHandler( event ) {
327 const { ref } = getElement();
328 if ( ref.contains( event.target ) ) {
329 return;
330 }
331 const context = getContext();
332 context.comboboxOpen = false;
333 },
334 },
335 callbacks: {
336 /**
337 * Sets flag text by modifying existing text node data (nodeType 3 = TEXT_NODE)
338 * instead of replacing textContent, to avoid triggering wp-emoji's MutationObserver
339 * which only watches childList (not characterData) and converts emoji to SVG images.
340 */
341 updateSelectedFlag() {
342 const { ref } = getElement();
343 const context = getContext();
344 setFlagText( ref, context.selectedCountry?.flag );
345 },
346 updateOptionFlag() {
347 const { ref } = getElement();
348 const context = getContext();
349 setFlagText( ref, context.filtered?.flag );
350 },
351 registerPhoneInput() {
352 const element = getElement().ref;
353 const context = getContext();
354 phoneInputRefs[ context.fieldId ] = element;
355 },
356 registerPhoneComboboxSearchInput() {
357 const element = getElement().ref;
358 const context = getContext();
359 searchInputRefs[ context.fieldId ] = element;
360 },
361 registerPhoneComboboxOptionsList() {
362 const element = getElement().ref;
363 const context = getContext();
364 optionsListRefs[ context.fieldId ] = element;
365 },
366 initializePhoneFieldCustomComboBox() {
367 const context = getContext();
368 ensureInitialized( context.fieldId );
369 },
370 },
371 } );
372