PageSourceSearch

https://www.commune-nointel.fr/wp-content/plugins/hob-formchat-pro/assets/js/hob-address.js?ver=1.29

js commune-nointel.fr collected 2026-10-03 02:46:40 UTC 31,151 bytes, 794 lines download raw bytes

1/**
2 * HOB FormChat Pro — Address Autocomplete Module
3 *
4 * Supports 3 providers:
5 *   - BAN  (Base Adresse Nationale — French government API)
6 *   - Google Places
7 *   - Nominatim / Photon (OpenStreetMap fallback)
8 *
9 * Config is read from window.hobFcAddress, localized by WordPress.
10 *
11 * @package HOB_FormChat_Pro
12 * @since   1.0.0
13 * @license GPLv2+
14 * @copyright 2026 HOB France
15 */
16
17'use strict';
18(function () {
19
20    var CFG = window.hobFcAddress || {};
21
22    /* ------------------------------------------------------------------ */
23    /*  Utilities                                                         */
24    /* ------------------------------------------------------------------ */
25
26    /**
27     * Basic HTML entity escaping.
28     *
29     * @param {string} str
30     * @return {string}
31     */
32    function escHtml(str) {
33        if (!str) return '';
34        return String(str)
35            .replace(/&/g, '&')
36            .replace(/</g, '&lt;')
37            .replace(/>/g, '&gt;')
38            .replace(/"/g, '&quot;');
39    }
40
41    /**
42     * Simple debounce helper.
43     *
44     * @param {Function} fn
45     * @param {number}   delay
46     * @return {Function}
47     */
48    function debounce(fn, delay) {
49        var timer = null;
50        return function () {
51            var ctx = this;
52            var args = arguments;
53            if (timer) clearTimeout(timer);
54            timer = setTimeout(function () {
55                fn.apply(ctx, args);
56            }, delay);
57        };
58    }
59
60    /* ------------------------------------------------------------------ */
61    /*  1. resolveProvider                                                 */
62    /* ------------------------------------------------------------------ */
63
64    /**
65     * Determine which provider to use.
66     *
67     * Priority: explicit forceProvider > auto detection.
68     * Auto mode: BAN if France scope, Google if key configured, else Nominatim.
69     * France scope = country includes "fr" or is empty.
70     *
71     * @param {string} fieldCountry  Country restriction from the field (e.g. "fr").
72     * @param {string} forceProvider Explicit provider override.
73     * @return {string} "ban"|"google"|"nominatim"
74     */
75    function resolveProvider(fieldCountry, forceProvider) {
76        if (forceProvider && forceProvider !== 'auto') {
77            return forceProvider;
78        }
79
80        var country = (fieldCountry || '').toLowerCase().trim();
81        var isFranceScope = !country || country === 'fr';
82
83        if (isFranceScope) {
84            return 'ban';
85        }
86
87        if (CFG.googleKey) {
88            return 'google';
89        }
90
91        return 'nominatim';
92    }
93
94    /* ------------------------------------------------------------------ */
95    /*  11. hideSuggestions                                                */
96    /* ------------------------------------------------------------------ */
97
98    /**
99     * Hide the suggestion list and clear ARIA attributes.
100     *
101     * @param {HTMLElement} sugList
102     */
103    function hideSuggestions(sugList) {
104        sugList.classList.remove('visible');
105        var input = sugList.parentNode
106            ? sugList.parentNode.querySelector('.hob-fc-address-input')
107            : null;
108        if (input) {
109            input.removeAttribute('aria-expanded');
110        }
111    }
112
113    /* ------------------------------------------------------------------ */
114    /*  9. initKeyboardNav                                                */
115    /* ------------------------------------------------------------------ */
116
117    /**
118     * Keyboard navigation inside the suggestion list.
119     *
120     * @param {HTMLInputElement} input
121     * @param {HTMLElement}      sugList
122     */
123    function initKeyboardNav(input, sugList) {
124        var currentIdx = -1;
125
126        input.addEventListener('keydown', function (e) {
127            var items = sugList.querySelectorAll('li:not(.hob-fc-address-suggestion--disabled):not(.hob-fc-address-suggestion--footer)');
128            if (!items.length) return;
129
130            if (e.key === 'ArrowDown' || e.keyCode === 40) {
131                e.preventDefault();
132                currentIdx = (currentIdx + 1 < items.length) ? currentIdx + 1 : 0;
133                setActive(items, currentIdx);
134            } else if (e.key === 'ArrowUp' || e.keyCode === 38) {
135                e.preventDefault();
136                currentIdx = (currentIdx - 1 >= 0) ? currentIdx - 1 : items.length - 1;
137                setActive(items, currentIdx);
138            } else if (e.key === 'Enter' || e.keyCode === 13) {
139                e.preventDefault();
140                if (currentIdx >= 0 && currentIdx < items.length) {
141                    items[currentIdx].click();
142                }
143                currentIdx = -1;
144            } else if (e.key === 'Escape' || e.keyCode === 27) {
145                e.preventDefault();
146                hideSuggestions(sugList);
147                currentIdx = -1;
148            }
149        });
150
151        function setActive(items, idx) {
152            for (var i = 0; i < items.length; i++) {
153                items[i].removeAttribute('aria-selected');
154                items[i].classList.remove('hob-fc-address-suggestion--active');
155            }
156            if (items[idx]) {
157                items[idx].setAttribute('aria-selected', 'true');
158                items[idx].classList.add('hob-fc-address-suggestion--active');
159                items[idx].scrollIntoView({ block: 'nearest' });
160            }
161        }
162    }
163
164    /* ------------------------------------------------------------------ */
165    /*  10. initClickOutside                                              */
166    /* ------------------------------------------------------------------ */
167
168    /**
169     * Close suggestion list when clicking outside the wrapper.
170     *
171     * @param {HTMLInputElement} input
172     * @param {HTMLElement}      sugList
173     */
174    function initClickOutside(input, sugList) {
175        document.addEventListener('click', function (e) {
176            var wrapper = sugList.closest('.hob-fc-address-wrapper');
177            if (wrapper && !wrapper.contains(e.target)) {
178                hideSuggestions(sugList);
179            }
180        });
181    }
182
183    /* ------------------------------------------------------------------ */
184    /*  8. fillSubfields                                                  */
185    /* ------------------------------------------------------------------ */
186
187    /**
188     * Fill decomposed sub-fields from a parts object.
189     *
190     * Looks for inputs with id = fieldId + '_' + key, or name containing the key.
191     *
192     * @param {HTMLElement} wrapper
193     * @param {string}      fieldId
194     * @param {Object}      parts
195     */
196    function fillSubfields(wrapper, fieldId, parts) {
197        var keys = [
198            'street_number', 'street', 'zip', 'city',
199            'state', 'country', 'lat', 'lng',
200            'citycode', 'ban_id'
201        ];
202
203        keys.forEach(function (key) {
204            if (typeof parts[key] === 'undefined') return;
205
206            var value = parts[key] || '';
207
208            // Try id-based lookup first
209            var el = wrapper.querySelector('#' + CSS.escape(fieldId + '_' + key));
210
211            // Fallback: any input whose name contains the key
212            if (!el) {
213                el = wrapper.querySelector('input[name*="' + key + '"]');
214            }
215
216            if (el) {
217                el.value = value;
218                // Trigger change for any framework listeners
219                var evt = new Event('change', { bubbles: true });
220                el.dispatchEvent(evt);
221            }
222        });
223    }
224
225    /* ------------------------------------------------------------------ */
226    /*  4. renderBanSuggestions                                            */
227    /* ------------------------------------------------------------------ */
228
229    /**
230     * Render BAN suggestion items into the list.
231     *
232     * @param {HTMLElement} sugList
233     * @param {Array}       features  GeoJSON features from BAN API
234     * @param {Function}    onSelect  Callback receiving a feature object
235     */
236    function renderBanSuggestions(sugList, features, onSelect) {
237        sugList.innerHTML = '';
238
239        if (!features || !features.length) {
240            var empty = document.createElement('li');
241            empty.className = 'hob-fc-address-suggestion hob-fc-address-suggestion--disabled';
242            empty.setAttribute('role', 'option');
243            empty.setAttribute('aria-disabled', 'true');
244            empty.textContent = 'Aucune adresse trouv\u00e9e';
245            sugList.appendChild(empty);
246            sugList.classList.add('visible');
247            return;
248        }
249
250        var typeIcons = {
251            housenumber: '\uD83C\uDFE0',  // house
252            street:      '\uD83D\uDEE3\uFE0F', // road
253            locality:    '\uD83C\uDFD8\uFE0F', // houses
254            municipality:'\uD83C\uDFD9\uFE0F'  // city
255        };
256
257        features.forEach(function (feat, idx) {
258            var p = feat.properties || {};
259            var li = document.createElement('li');
260            li.className = 'hob-fc-address-suggestion';
261            li.setAttribute('role', 'option');
262            li.setAttribute('id', 'hob-fc-sug-' + idx);
263
264            var icon = typeIcons[p.type] || '\uD83D\uDCCD'; // pin fallback
265            var mainText = escHtml(p.name || p.label || '');
266            var contextParts = (p.context || '').split(',');
267            var department = contextParts.length > 1 ? contextParts[1].trim() : '';
268            var secondary = [p.postcode, p.city, department].filter(Boolean).join(' \u2014 ');
269
270            li.innerHTML =
271                '<span class="hob-fc-address-suggestion__icon">' + icon + '</span>' +
272                '<span class="hob-fc-address-suggestion__content">' +
273                    '<span class="hob-fc-address-suggestion__main">' + mainText + '</span>' +
274                    '<span class="hob-fc-address-suggestion__secondary">' + escHtml(secondary) + '</span>' +
275                '</span>' +
276                '<span class="hob-fc-address-suggestion__badge ban">BAN</span>';
277
278            li.addEventListener('click', function () {
279                onSelect(feat);
280            });
281
282            li.addEventListener('mouseenter', function () {
283                var all = sugList.querySelectorAll('.hob-fc-address-suggestion');
284                for (var i = 0; i < all.length; i++) {
285                    all[i].removeAttribute('aria-selected');
286                }
287                li.setAttribute('aria-selected', 'true');
288            });
289
290            sugList.appendChild(li);
291        });
292
293        // Powered-by footer
294        var footer = document.createElement('li');
295        footer.className = 'hob-fc-address-suggestion hob-fc-address-suggestion--footer';
296        footer.setAttribute('aria-disabled', 'true');
297        footer.innerHTML =
298            'Donn\u00e9es \u00a9 <a href="https://adresse.data.gouv.fr" target="_blank" rel="noopener">Base Adresse Nationale</a>' +
299            ' \u2014 Etalab / La Poste / IGN';
300        sugList.appendChild(footer);
301
302        sugList.classList.add('visible');
303        var input = sugList.parentNode
304            ? sugList.parentNode.querySelector('.hob-fc-address-input')
305            : null;
306        if (input) {
307            input.setAttribute('aria-expanded', 'true');
308        }
309    }
310
311    /* ------------------------------------------------------------------ */
312    /*  5. selectBanSuggestion                                            */
313    /* ------------------------------------------------------------------ */
314
315    /**
316     * Handle selection of a BAN suggestion.
317     *
318     * @param {Object}       feat       GeoJSON feature
319     * @param {HTMLInputElement} input
320     * @param {HTMLElement}  sugList
321     * @param {HTMLInputElement} fullInput
322     * @param {HTMLElement}  wrapper
323     * @param {string}       fieldId
324     */
325    function selectBanSuggestion(feat, input, sugList, fullInput, wrapper, fieldId) {
326        var p = feat.properties || {};
327        var coords = (feat.geometry && feat.geometry.coordinates) ? feat.geometry.coordinates : [null, null];
328        var lng = coords[0];
329        var lat = coords[1];
330
331        var contextParts = (p.context || '').split(',');
332        var state = contextParts.length > 1 ? contextParts[1].trim() : '';
333
334        var parts = {
335            street_number: p.housenumber || '',
336            street:        p.street || p.name || '',
337            zip:           p.postcode || '',
338            city:          p.city || '',
339            citycode:      p.citycode || '',
340            state:         state,
341            country:       'France',
342            lat:           lat != null ? String(lat) : '',
343            lng:           lng != null ? String(lng) : '',
344            ban_id:        p.id || '',
345            full:          p.label || ''
346        };
347
348        input.value = parts.full;
349        if (fullInput) {
350            fullInput.value = parts.full;
351        }
352
353        fillSubfields(wrapper, fieldId, parts);
354
355        // Set hidden citycode and ban_id fields (outside wrapper too)
356        var form = wrapper.closest('form');
357        if (form) {
358            var citycodeEl = form.querySelector('input[name*="citycode"]');
359            if (citycodeEl) citycodeEl.value = parts.citycode;
360            var banIdEl = form.querySelector('input[name*="ban_id"]');
361            if (banIdEl) banIdEl.value = parts.ban_id;
362        }
363
364        hideSuggestions(sugList);
365    }
366
367    /* ------------------------------------------------------------------ */
368    /*  3. initBanAutocomplete                                            */
369    /* ------------------------------------------------------------------ */
370
371    /**
372     * Initialise BAN (Base Adresse Nationale) autocomplete on a field.
373     *
374     * @param {HTMLElement}      wrapper
375     * @param {HTMLInputElement} input
376     * @param {HTMLElement}      sugList
377     * @param {HTMLElement}      spinner
378     * @param {HTMLInputElement} fullInput
379     * @param {string}           fieldId
380     * @param {string}           country
381     * @param {string}           mode
382     */
383    function initBanAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode) {
384        var BAN_URL = CFG.banUrl || 'https://api-adresse.data.gouv.fr/search/';
385        var abortCtrl = null;
386
387        var onInput = debounce(function () {
388            var query = input.value.trim();
389
390            if (query.length < (CFG.minChars || 3)) {
391                hideSuggestions(sugList);
392                return;
393            }
394
395            // Build parameters
396            var params = new URLSearchParams();
397            params.set('q', query);
398            params.set('limit', '6');
399            params.set('autocomplete', '1');
400
401            // Detect 5-digit postcode
402            var pcMatch = query.match(/\b(\d{5})\b/);
403            if (pcMatch) {
404                params.set('postcode', pcMatch[1]);
405            }
406
407            // Show spinner
408            spinner.classList.add('visible');
409
410            // Abort previous request
411            if (abortCtrl) {
412                try { abortCtrl.abort(); } catch (ignore) {}
413            }
414            abortCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
415
416            var fetchOpts = {};
417            if (abortCtrl) {
418                fetchOpts.signal = abortCtrl.signal;
419            }
420
421            fetch(BAN_URL + '?' + params.toString(), fetchOpts)
422                .then(function (res) { return res.json(); })
423                .then(function (data) {
424                    spinner.classList.remove('visible');
425                    var features = data.features || [];
426                    renderBanSuggestions(sugList, features, function (feat) {
427                        selectBanSuggestion(feat, input, sugList, fullInput, wrapper, fieldId);
428                    });
429                })
430                .catch(function (err) {
431                    spinner.classList.remove('visible');
432                    if (err && err.name === 'AbortError') return;
433                    console.warn('[HOB Address] BAN request failed, falling back to Nominatim:', err);
434                    // Silent fallback to Nominatim
435                    initNominatimAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode);
436                });
437        }, CFG.debounceMs || 280);
438
439        input.addEventListener('input', onInput);
440
441        initKeyboardNav(input, sugList);
442        initClickOutside(input, sugList);
443    }
444
445    /* ------------------------------------------------------------------ */
446    /*  6. initNominatimAutocomplete                                      */
447    /* ------------------------------------------------------------------ */
448
449    /**
450     * Initialise Nominatim / Photon autocomplete on a field.
451     *
452     * @param {HTMLElement}      wrapper
453     * @param {HTMLInputElement} input
454     * @param {HTMLElement}      sugList
455     * @param {HTMLElement}      spinner
456     * @param {HTMLInputElement} fullInput
457     * @param {string}           fieldId
458     * @param {string}           country
459     * @param {string}           mode
460     */
461    function initNominatimAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode) {
462        var PHOTON_URL = CFG.photonUrl || 'https://photon.komoot.io/api/';
463        var abortCtrl = null;
464
465        // Remove any previous BAN listener by replacing the input
466        var fresh = input.cloneNode(true);
467        input.parentNode.replaceChild(fresh, input);
468        input = fresh;
469
470        var onInput = debounce(function () {
471            var query = input.value.trim();
472
473            if (query.length < (CFG.minChars || 3)) {
474                hideSuggestions(sugList);
475                return;
476            }
477
478            var params = new URLSearchParams();
479            params.set('q', query);
480            params.set('limit', '5');
481            params.set('lang', 'fr');
482
483            // Country bias
484            if (country) {
485                params.set('osm_tag', 'place');
486                // Photon doesn't have a direct country param but we can use bbox or lang
487                // We pass the country hint via the "location_bias" workaround if France
488                // For a simple implementation, we include country name in the query
489            }
490
491            spinner.classList.add('visible');
492
493            if (abortCtrl) {
494                try { abortCtrl.abort(); } catch (ignore) {}
495            }
496            abortCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
497
498            var fetchOpts = {};
499            if (abortCtrl) {
500                fetchOpts.signal = abortCtrl.signal;
501            }
502
503            fetch(PHOTON_URL + '?' + params.toString(), fetchOpts)
504                .then(function (res) { return res.json(); })
505                .then(function (data) {
506                    spinner.classList.remove('visible');
507                    var features = data.features || [];
508                    renderNominatimSuggestions(sugList, features, function (feat) {
509                        selectNominatimSuggestion(feat, input, sugList, fullInput, wrapper, fieldId);
510                    });
511                })
512                .catch(function (err) {
513                    spinner.classList.remove('visible');
514                    if (err && err.name === 'AbortError') return;
515                    console.warn('[HOB Address] Photon/Nominatim request failed:', err);
516                });
517        }, CFG.debounceMs || 280);
518
519        input.addEventListener('input', onInput);
520
521        initKeyboardNav(input, sugList);
522        initClickOutside(input, sugList);
523    }
524
525    /**
526     * Render Nominatim/Photon suggestion items.
527     *
528     * @param {HTMLElement} sugList
529     * @param {Array}       features
530     * @param {Function}    onSelect
531     */
532    function renderNominatimSuggestions(sugList, features, onSelect) {
533        sugList.innerHTML = '';
534
535        if (!features || !features.length) {
536            var empty = document.createElement('li');
537            empty.className = 'hob-fc-address-suggestion hob-fc-address-suggestion--disabled';
538            empty.setAttribute('role', 'option');
539            empty.setAttribute('aria-disabled', 'true');
540            empty.textContent = 'Aucune adresse trouv\u00e9e';
541            sugList.appendChild(empty);
542            sugList.classList.add('visible');
543            return;
544        }
545
546        features.forEach(function (feat, idx) {
547            var p = feat.properties || {};
548            var li = document.createElement('li');
549            li.className = 'hob-fc-address-suggestion';
550            li.setAttribute('role', 'option');
551            li.setAttribute('id', 'hob-fc-sug-nom-' + idx);
552
553            var mainText = escHtml(p.name || '');
554            var secondaryParts = [p.postcode, p.city, p.state, p.country].filter(Boolean);
555            var secondary = secondaryParts.join(' \u2014 ');
556
557            li.innerHTML =
558                '<span class="hob-fc-address-suggestion__icon">\uD83C\uDF0D</span>' +
559                '<span class="hob-fc-address-suggestion__content">' +
560                    '<span class="hob-fc-address-suggestion__main">' + mainText + '</span>' +
561                    '<span class="hob-fc-address-suggestion__secondary">' + escHtml(secondary) + '</span>' +
562                '</span>' +
563                '<span class="hob-fc-address-suggestion__badge osm">OSM</span>';
564
565            li.addEventListener('click', function () {
566                onSelect(feat);
567            });
568
569            li.addEventListener('mouseenter', function () {
570                var all = sugList.querySelectorAll('.hob-fc-address-suggestion');
571                for (var i = 0; i < all.length; i++) {
572                    all[i].removeAttribute('aria-selected');
573                }
574                li.setAttribute('aria-selected', 'true');
575            });
576
577            sugList.appendChild(li);
578        });
579
580        // Powered-by footer
581        var footer = document.createElement('li');
582        footer.className = 'hob-fc-address-suggestion hob-fc-address-suggestion--footer';
583        footer.setAttribute('aria-disabled', 'true');
584        footer.innerHTML = 'Donn\u00e9es \u00a9 <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors';
585        sugList.appendChild(footer);
586
587        sugList.classList.add('visible');
588        var input = sugList.parentNode
589            ? sugList.parentNode.querySelector('.hob-fc-address-input')
590            : null;
591        if (input) {
592            input.setAttribute('aria-expanded', 'true');
593        }
594    }
595
596    /**
597     * Handle selection of a Nominatim/Photon suggestion.
598     *
599     * @param {Object}          feat
600     * @param {HTMLInputElement} input
601     * @param {HTMLElement}      sugList
602     * @param {HTMLInputElement} fullInput
603     * @param {HTMLElement}      wrapper
604     * @param {string}           fieldId
605     */
606    function selectNominatimSuggestion(feat, input, sugList, fullInput, wrapper, fieldId) {
607        var p = feat.properties || {};
608        var coords = (feat.geometry && feat.geometry.coordinates) ? feat.geometry.coordinates : [null, null];
609        var lng = coords[0];
610        var lat = coords[1];
611
612        var streetParts = [];
613        if (p.housenumber) streetParts.push(p.housenumber);
614        if (p.street) streetParts.push(p.street);
615        var fullAddress = [p.name || streetParts.join(' '), p.postcode, p.city, p.country].filter(Boolean).join(', ');
616
617        var parts = {
618            street_number: p.housenumber || '',
619            street:        p.street || p.name || '',
620            zip:           p.postcode || '',
621            city:          p.city || '',
622            state:         p.state || '',
623            country:       p.country || '',
624            lat:           lat != null ? String(lat) : '',
625            lng:           lng != null ? String(lng) : '',
626            full:          fullAddress
627        };
628
629        input.value = parts.full;
630        if (fullInput) {
631            fullInput.value = parts.full;
632        }
633
634        fillSubfields(wrapper, fieldId, parts);
635        hideSuggestions(sugList);
636    }
637
638    /* ------------------------------------------------------------------ */
639    /*  7. initGoogleAutocomplete                                         */
640    /* ------------------------------------------------------------------ */
641
642    /**
643     * Initialise Google Places autocomplete on a field.
644     *
645     * @param {HTMLElement}      wrapper
646     * @param {HTMLInputElement} input
647     * @param {HTMLElement}      sugList
648     * @param {HTMLElement}      spinner
649     * @param {HTMLInputElement} fullInput
650     * @param {string}           fieldId
651     * @param {string}           country
652     * @param {string}           mode
653     */
654    function initGoogleAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode) {
655        if (typeof google === 'undefined' || !google.maps || !google.maps.places) {
656            console.warn('[HOB Address] Google Maps Places API not loaded. Falling back to Nominatim.');
657            initNominatimAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode);
658            return;
659        }
660
661        var options = {
662            types: ['address'],
663            fields: ['address_components', 'formatted_address', 'geometry']
664        };
665
666        if (country) {
667            options.componentRestrictions = { country: country };
668        }
669
670        var autocomplete = new google.maps.places.Autocomplete(input, options);
671
672        autocomplete.addListener('place_changed', function () {
673            var place = autocomplete.getPlace();
674            if (!place || !place.address_components) return;
675
676            var components = place.address_components;
677            var parts = {
678                street_number: '',
679                street:        '',
680                zip:           '',
681                city:          '',
682                state:         '',
683                country:       '',
684                lat:           '',
685                lng:           '',
686                full:          place.formatted_address || ''
687            };
688
689            // Map Google component types to our parts keys
690            var typeMap = {
691                'street_number':                'street_number',
692                'route':                        'street',
693                'postal_code':                  'zip',
694                'locality':                     'city',
695                'administrative_area_level_1':  'state',
696                'country':                      'country'
697            };
698
699            components.forEach(function (comp) {
700                comp.types.forEach(function (type) {
701                    if (typeMap[type]) {
702                        parts[typeMap[type]] = (type === 'country')
703                            ? comp.long_name
704                            : comp.long_name;
705                    }
706                });
707            });
708
709            // Geometry
710            if (place.geometry && place.geometry.location) {
711                parts.lat = String(place.geometry.location.lat());
712                parts.lng = String(place.geometry.location.lng());
713            }
714
715            input.value = parts.full;
716            if (fullInput) {
717                fullInput.value = parts.full;
718            }
719
720            fillSubfields(wrapper, fieldId, parts);
721        });
722    }
723
724    /* ------------------------------------------------------------------ */
725    /*  2. initAddressField                                               */
726    /* ------------------------------------------------------------------ */
727
728    /**
729     * Initialise a single address field wrapper.
730     *
731     * @param {HTMLElement} wrapper  A .hob-fc-address-wrapper element.
732     */
733    function initAddressField(wrapper) {
734        var fieldId       = wrapper.getAttribute('data-field-id') || '';
735        var mode          = wrapper.getAttribute('data-mode') || 'autocomplete_decomposed';
736        var country       = wrapper.getAttribute('data-country') || CFG.defaultCountry || '';
737        var forceProvider = wrapper.getAttribute('data-force-provider') || '';
738
739        // Main input
740        var input = wrapper.querySelector('.hob-fc-address-input');
741        if (!input) return;
742
743        // Suggestion list — get or create
744        var sugList = wrapper.querySelector('.hob-fc-address-suggestions');
745        if (!sugList) {
746            sugList = document.createElement('ul');
747            sugList.className = 'hob-fc-address-suggestions';
748            sugList.setAttribute('role', 'listbox');
749            wrapper.appendChild(sugList);
750        }
751
752        // Spinner — get or create
753        var spinner = wrapper.querySelector('.hob-fc-address-spinner');
754        if (!spinner) {
755            spinner = document.createElement('span');
756            spinner.className = 'hob-fc-address-spinner';
757            wrapper.appendChild(spinner);
758        }
759
760        // Hidden full-address input
761        var fullInput = wrapper.querySelector('.hob-fc-address-full');
762
763        // ARIA
764        input.setAttribute('role', 'combobox');
765        input.setAttribute('aria-autocomplete', 'list');
766        input.setAttribute('aria-controls', sugList.id || '');
767        input.setAttribute('autocomplete', 'off');
768
769        // Resolve provider and dispatch
770        var provider = resolveProvider(country, forceProvider);
771
772        switch (provider) {
773            case 'ban':
774                initBanAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode);
775                break;
776            case 'google':
777                initGoogleAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode);
778                break;
779            case 'nominatim':
780            default:
781                initNominatimAutocomplete(wrapper, input, sugList, spinner, fullInput, fieldId, country, mode);
782                break;
783        }
784    }
785
786    /* ------------------------------------------------------------------ */
787    /*  Bootstrap                                                         */
788    /* ------------------------------------------------------------------ */
789
790    document.addEventListener('DOMContentLoaded', function () {
791        document.querySelectorAll('.hob-fc-address-wrapper').forEach(initAddressField);
792    });
793
794})();

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.