/**
* Address provider registration for WooCommerce shortcode checkout
*/
// Make functions and state available globally under window.wc.addressAutocomplete
window.wc = window.wc || {};
window.wc.addressAutocomplete = window.wc.addressAutocomplete || {
providers: {},
activeProvider: { billing: null, shipping: null },
};
/**
* Register an address autocomplete provider
*
* @param {Object} provider The provider object
* @return {boolean} Whether the registration was successful
*/
function registerAddressAutocompleteProvider( provider ) {
try {
// Check required properties
if ( ! provider || typeof provider !== 'object' ) {
throw new Error( 'Address provider must be a valid object' );
}
if ( ! provider.id || typeof provider.id !== 'string' ) {
throw new Error( 'Address provider must have a valid ID' );
}
if ( typeof provider.canSearch !== 'function' ) {
throw new Error(
'Address provider must have a canSearch function'
);
}
if ( typeof provider.search !== 'function' ) {
throw new Error( 'Address provider must have a search function' );
}
if ( typeof provider.select !== 'function' ) {
throw new Error( 'Address provider must have a select function' );
}
// Check if provider is registered on server.
var serverProviders = [];
if (
window &&
window.wc_checkout_params &&
Array.isArray( window.wc_checkout_params.address_providers ) &&
window.wc_checkout_params.address_providers.length > 0
) {
serverProviders = window.wc_checkout_params.address_providers;
}
if ( ! Array.isArray( serverProviders ) ) {
throw new Error( 'Server providers configuration is invalid' );
}
var isRegistered = serverProviders.some( function ( serverProvider ) {
return (
serverProvider &&
typeof serverProvider === 'object' &&
typeof serverProvider.id === 'string' &&
serverProvider.id === provider.id
);
} );
if ( ! isRegistered ) {
throw new Error(
'Provider ' + provider.id + ' not registered on server'
);
}
// Check if a provider with the same ID already exists
if ( window.wc.addressAutocomplete.providers[ provider.id ] ) {
console.warn(
'Address provider with ID "' +
provider.id +
'" is already registered.'
);
return false;
}
// Freeze and add provider to registry.
Object.freeze( provider );
window.wc.addressAutocomplete.providers[ provider.id ] = provider;
return true;
} catch ( error ) {
console.error( 'Error registering address provider:', error.message );
return false;
}
}
// Export the registration function
window.wc.addressAutocomplete.registerAddressAutocompleteProvider =
registerAddressAutocompleteProvider;
( function () {
/**
* Set the active address provider based on which providers' (queried in order) canSearch returns true.
* Triggers when country changes.
* @param country {string} country code.
* @param type {string} type 'billing' or 'shipping'
*/
function setActiveProvider( country, type ) {
// Get server providers list (already ordered by preference).
const serverProviders =
( window &&
window.wc_checkout_params &&
window.wc_checkout_params.address_providers ) ||
[];
// Check providers in preference order (server handles preferred provider ordering).
for ( const serverProvider of serverProviders ) {
const provider =
window.wc.addressAutocomplete.providers[ serverProvider.id ];
if ( provider && provider.canSearch( country ) ) {
window.wc.addressAutocomplete.activeProvider[ type ] = provider;
// Add autocomplete-available class to parent .woocommerce-input-wrapper
const addressInput = document.getElementById(
`${ type }_address_1`
);
if ( addressInput ) {
const wrapper = addressInput.closest(
'.woocommerce-input-wrapper'
);
if ( wrapper ) {
wrapper.classList.add( 'autocomplete-available' );
}
}
return;
}
}
// No provider can search for this country.
window.wc.addressAutocomplete.activeProvider[ type ] = null;
// Remove autocomplete-available class from parent .woocommerce-input-wrapper
const addressInput = document.getElementById( `${ type }_address_1` );
if ( addressInput ) {
const wrapper = addressInput.closest(
'.woocommerce-input-wrapper'
);
if ( wrapper ) {
wrapper.classList.remove( 'autocomplete-available' );
}
}
}
document.addEventListener( 'DOMContentLoaded', function () {
// This script would not be enqueued if the feature was not enabled.
const addressTypes = [ 'billing', 'shipping' ];
const addressInputs = {};
const suggestionsContainers = {};
const suggestionsLists = {};
let activeSuggestionIndices = {};
let addressSelectionTimeout;
const blurHandlers = {};
/**
* Cache address fields for a given type, will re-run when country changes.
* @param type
* @return {{address_2: HTMLElement, city: HTMLElement, country: HTMLElement, postcode: HTMLElement}}
*/
function cacheAddressFields( type ) {
addressInputs[ type ] = {};
addressInputs[ type ][ 'address_1' ] = document.getElementById(
`${ type }_address_1`
);
addressInputs[ type ][ 'city' ] = document.getElementById(
`${ type }_city`
);
addressInputs[ type ][ 'country' ] = document.getElementById(
`${ type }_country`
);
addressInputs[ type ][ 'postcode' ] = document.getElementById(
`${ type }_postcode`
);
addressInputs[ type ][ 'state' ] = document.getElementById(
`${ type }_state`
);
}
// Initialize for both billing and shipping.
addressTypes.forEach( ( type ) => {
cacheAddressFields( type );
const addressInput = addressInputs[ type ][ 'address_1' ];
const cityInput = addressInputs[ type ][ 'city' ];
const countryInput = addressInputs[ type ][ 'country' ];
const postcodeInput = addressInputs[ type ][ 'postcode' ];
if ( addressInput ) {
// Create suggestions container if it doesn't exist.
if (
! document.getElementById( `address_suggestions_${ type }` )
) {
const container = document.createElement( 'div' );
container.id = `address_suggestions_${ type }`;
container.className = 'woocommerce-address-suggestions';
container.style.display = 'none';
container.setAttribute( 'role', 'region' );
container.setAttribute( 'aria-live', 'polite' );
const list = document.createElement( 'ul' );
list.className = 'suggestions-list';
list.setAttribute( 'role', 'listbox' );
list.setAttribute( 'aria-label', 'Address suggestions' );
container.appendChild( list );
addressInput.parentNode.insertBefore(
container,
addressInput.nextSibling
);
// Add search icon.
const searchIcon = document.createElement( 'div' );
searchIcon.className = 'address-search-icon';
addressInput.parentNode.appendChild( searchIcon );
}
addressInputs[ type ] = {};
addressInputs[ type ][ 'address_1' ] = addressInput;
addressInputs[ type ][ 'city' ] = cityInput;
addressInputs[ type ][ 'country' ] = countryInput;
addressInputs[ type ][ 'postcode' ] = postcodeInput;
suggestionsContainers[ type ] = document.getElementById(
`address_suggestions_${ type }`
);
suggestionsLists[ type ] =
suggestionsContainers[ type ].querySelector(
'.suggestions-list'
);
activeSuggestionIndices[ type ] = -1;
}
// Get country value and set active address provider based on it.
if ( countryInput ) {
setActiveProvider( countryInput.value, type );
/**
* Listen for country changes to re-evaluate provider availability.
* Handle both regular change events and Select2 events.
*/
const handleCountryChange = function () {
cacheAddressFields( type );
setActiveProvider( countryInput.value, type );
if ( addressInputs[ type ][ 'address_1' ] ) {
hideSuggestions( type );
}
};
countryInput.addEventListener( 'change', handleCountryChange );
// Also listen for Select2 change event if jQuery and Select2 are available.
if ( window.jQuery && window.jQuery( countryInput ).select2 ) {
window
.jQuery( countryInput )
.on( 'select2:select', handleCountryChange );
}
}
} );
/**
* Disable browser autofill for address inputs to prevent conflicts with autocomplete.
* @param input {HTMLInputElement} The input element to disable autofill for.
*/
function disableBrowserAutofill( input ) {
if ( input.getAttribute( 'autocomplete' ) === 'off' ) {
return;
}
input.setAttribute( 'autocomplete', 'off' );
input.setAttribute( 'data-lpignore', 'true' );
input.setAttribute( 'data-op-ignore', 'true' );
input.setAttribute( 'data-1p-ignore', 'true' );
// To prevent 1Password/LastPass and autocomplete clashes, we need to refocus the element.
// This is achieved by removing and re-adding the element to trigger browser updates.
const parentElement = input.parentElement;
if ( parentElement ) {
parentElement.appendChild( parentElement.removeChild( input ) );
input.focus();
}
}
/**
* Enable browser autofill for address input.
* @param input {HTMLInputElement} The input element to enable autofill for.
* @param shouldFocus {boolean} Whether to focus the input after enabling autofill.
*/
function enableBrowserAutofill( input, shouldFocus = true ) {
if ( input.getAttribute( 'autocomplete' ) !== 'off' ) {
return;
}
input.setAttribute( 'autocomplete', 'address-line1' );
input.setAttribute( 'data-lpignore', 'false' );
input.setAttribute( 'data-op-ignore', 'false' );
input.setAttribute( 'data-1p-ignore', 'false' );
// To ensure browser updates and re-enables autofill, we need to refocus the element.
// This is achieved by removing and re-adding the element to trigger browser updates.
const parentElement = input.parentElement;
if ( parentElement ) {
parentElement.appendChild( parentElement.removeChild( input ) );
if ( shouldFocus ) {
input.focus();
}
}
}
/**
* Get highlighted label parts based on matches returned by `search` results.
* @param label {string} The label to highlight.
* @param matches {*[]} Array of match objects with `offset` and `length`.
* @return {*[]} Array of nodes with highlighted parts.
*/
function getHighlightedLabel( label, matches ) {
// Sanitize label for display.
const sanitizedLabel = sanitizeForDisplay( label );
const parts = [];
let lastIndex = 0;
// Validate matches array.
if ( ! Array.isArray( matches ) ) {
// If matches is invalid, just return plain text.
parts.push( document.createTextNode( sanitizedLabel ) );
return parts;
}
// Validate matches.
const safeMatches = matches.filter(
( match ) =>
match &&
typeof match.offset === 'number' &&
typeof match.length === 'number' &&
match.offset >= 0 &&
match.length > 0 &&
match.offset + match.length <= sanitizedLabel.length
);
safeMatches.forEach( ( match ) => {
// Add text before match.
if ( match.offset > lastIndex ) {
parts.push(
document.createTextNode(
sanitizedLabel.slice( lastIndex, match.offset )
)
);
}
// Add bold matched text.
const bold = document.createElement( 'strong' );
bold.textContent = sanitizedLabel.slice(
match.offset,
match.offset + match.length
);
parts.push( bold );
lastIndex = match.offset + match.length;
} );
// Add remaining text.
if ( lastIndex < sanitizedLabel.length ) {
parts.push(
document.createTextNode( sanitizedLabel.slice( lastIndex ) )
);
}
return parts;
}
/**
* Sanitize HTML for display by removing any HTML tags.
*
* @param html
* @return {string|string}
*/
function sanitizeForDisplay( html ) {
const doc = document.implementation.createHTMLDocument( '' );
doc.body.innerHTML = html;
return doc.body.textContent || '';
}
/**
* Handle searching and displaying autocomplete results below the address input if the value meets the criteria
* of 3 or more characters. No suggestion is initially highlighted.
* @param inputValue {string} The value entered into the address input.
* @param country {string} The country code to pass to the provider's search method.
* @param type {string} The address type ('billing' or 'shipping').
* @return {Promise