/**
* Address provider implementation for WooCommerce shortcode checkout
*
* Note: The core registration logic and provider management is handled
* by the common module (address-autocomplete-common.js). This file focuses
* on the shortcode-specific implementation.
*/
// The common module will have already initialized window.wc.addressAutocomplete
// with providers, activeProvider, serverProviders, and the registration function.
// We just need to use them here.
if (
! window.wc ||
! window.wc.wcSettings ||
! window.wc.wcSettings.allSettings ||
! window.wc.wcSettings.allSettings.isCheckoutBlock
) {
( 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.wc.addressAutocomplete.serverProviders;
// 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' );
}
// Add combobox role and ARIA attributes for accessibility
addressInput.setAttribute( 'role', 'combobox' );
addressInput.setAttribute(
'aria-autocomplete',
'list'
);
addressInput.setAttribute( 'aria-expanded', 'false' );
addressInput.setAttribute( 'aria-haspopup', 'listbox' );
}
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' );
}
// Remove all ARIA attributes when no provider is available
addressInput.removeAttribute( 'role' );
addressInput.removeAttribute( 'aria-autocomplete' );
addressInput.removeAttribute( 'aria-expanded' );
addressInput.removeAttribute( 'aria-haspopup' );
addressInput.removeAttribute( 'aria-activedescendant' );
addressInput.removeAttribute( 'aria-owns' );
addressInput.removeAttribute( 'aria-controls' );
}
}
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 ][ 'address_2' ] = document.getElementById(
`${ type }_address_2`
);
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 countryInput = addressInputs[ type ][ 'country' ];
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 );
}
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 );
// Remove branding element when country changes
if ( suggestionsContainers[ type ] ) {
const brandingElement = suggestionsContainers[
type
].querySelector(
'.woocommerce-address-autocomplete-branding'
);
if ( brandingElement ) {
brandingElement.remove();
}
}
}
};
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' ) === 'none' ) {
return;
}
input.setAttribute( 'autocomplete', 'none' );
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 ) {
// Store the current value to preserve it
const currentValue = input.value;
// Mark that we're manipulating the DOM to prevent checkout updates
input.setAttribute(
'data-autocomplete-manipulating',
'true'
);
parentElement.appendChild(
parentElement.removeChild( input )
);
// Restore the value if it was lost
if ( input.value !== currentValue ) {
input.value = currentValue;
}
// Remove the manipulation flag after a brief delay
setTimeout( function () {
input.removeAttribute(
'data-autocomplete-manipulating'
);
}, 10 );
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' ) !== 'none' ) {
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 ) {
// Store the current value to preserve it
const currentValue = input.value;
// Mark that we're manipulating the DOM to prevent checkout updates
input.setAttribute(
'data-autocomplete-manipulating',
'true'
);
parentElement.appendChild(
parentElement.removeChild( input )
);
// Restore the value if it was lost
if ( input.value !== currentValue ) {
input.value = currentValue;
}
// Remove the manipulation flag after a brief delay. Use two rAFs to ensure layout/assistive tech settle.
requestAnimationFrame( function () {
requestAnimationFrame( function () {
input.removeAttribute(
'data-autocomplete-manipulating'
);
} );
} );
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