Current File : /home/mdkeenpw/www/wp-content/plugins/woocommerce/assets/js/frontend/address-autocomplete.js |
/**
* 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<void>}
*/
async function displaySuggestions( inputValue, country, type ) {
// Sanitize input value.
const sanitizedInput = sanitizeForDisplay( inputValue );
if ( sanitizedInput !== inputValue ) {
console.warn( 'Input was sanitized for security' );
}
// Check if the address section exists (shipping may be disabled/hidden)
if (
! addressInputs[ type ] ||
! addressInputs[ type ][ 'address_1' ]
) {
return;
}
if (
! suggestionsLists[ type ] ||
! suggestionsContainers[ type ]
) {
return;
}
const addressInput = addressInputs[ type ][ 'address_1' ];
const suggestionsList = suggestionsLists[ type ];
const suggestionsContainer = suggestionsContainers[ type ];
// Hide suggestions if input has less than 3 characters
if ( sanitizedInput.length < 3 ) {
hideSuggestions( type );
enableBrowserAutofill( addressInput );
return;
}
// Check if we have an active provider for this address type.
if ( ! window.wc.addressAutocomplete.activeProvider[ type ] ) {
hideSuggestions( type );
enableBrowserAutofill( addressInput );
return;
}
try {
const filteredSuggestions =
await window.wc.addressAutocomplete.activeProvider[
type
].search( sanitizedInput, country, type );
// Validate suggestions array.
if ( ! Array.isArray( filteredSuggestions ) ) {
console.error(
'Invalid suggestions response - not an array'
);
hideSuggestions( type );
return;
}
// Limit number of suggestions, API may return many results but we should only show the first 5.
const maxSuggestions = 5;
const safeSuggestions = filteredSuggestions.slice(
0,
maxSuggestions
);
if ( safeSuggestions.length === 0 ) {
hideSuggestions( type );
return;
}
// Clear existing suggestions only when we have new results to show.
suggestionsList.innerHTML = '';
safeSuggestions.forEach( ( suggestion, index ) => {
const li = document.createElement( 'li' );
li.setAttribute( 'role', 'option' );
li.id = `suggestion-item-${ type }-${ index }`;
li.dataset.id = suggestion.id;
li.setAttribute( 'tabindex', '-1' );
li.textContent = ''; // Clear existing content.
const labelParts = getHighlightedLabel(
suggestion.label,
suggestion.matchedSubstrings || []
);
labelParts.forEach( ( part ) => li.appendChild( part ) );
li.addEventListener( 'click', async function () {
// Hide suggestions immediately for better UX.
hideSuggestions( type );
await selectAddress( type, this.dataset.id );
addressInput.focus();
} );
li.addEventListener( 'mouseenter', function () {
setActiveSuggestion( type, index );
} );
suggestionsList.appendChild( li );
} );
disableBrowserAutofill( addressInput );
suggestionsContainer.style.display = 'block';
suggestionsContainer.style.marginTop =
addressInputs[ type ][ 'address_1' ].offsetHeight + 'px';
addressInput.setAttribute( 'aria-expanded', 'true' );
addressInput.setAttribute(
'aria-owns',
`address_suggestions_${ type }_list`
);
suggestionsList.id = `address_suggestions_${ type }_list`;
// Don't auto-highlight first suggestion for better screen reader accessibility
activeSuggestionIndices[ type ] = -1;
// Add blur event listener when suggestions are shown
if ( ! blurHandlers[ type ] ) {
blurHandlers[ type ] = function () {
// Use a small delay to allow clicks on suggestions to register
setTimeout( () => {
hideSuggestions( type );
enableBrowserAutofill( addressInput, false );
}, 200 );
};
addressInput.addEventListener(
'blur',
blurHandlers[ type ]
);
}
} catch ( error ) {
console.error( 'Address search error:', error );
hideSuggestions( type );
enableBrowserAutofill( addressInput );
}
}
/**
* Hide the suggestions container for a given address type.
* @param type {string} The address type ('billing' or 'shipping').
*/
function hideSuggestions( type ) {
// Check if the address section exists (shipping may be disabled/hidden)
if (
! addressInputs[ type ] ||
! addressInputs[ type ][ 'address_1' ]
) {
return;
}
if (
! suggestionsLists[ type ] ||
! suggestionsContainers[ type ]
) {
return;
}
const suggestionsList = suggestionsLists[ type ];
const suggestionsContainer = suggestionsContainers[ type ];
const addressInput = addressInputs[ type ][ 'address_1' ];
suggestionsList.innerHTML = '';
suggestionsContainer.style.display = 'none';
addressInput.setAttribute( 'aria-expanded', 'false' );
addressInput.removeAttribute( 'aria-activedescendant' );
addressInput.removeAttribute( 'aria-owns' );
activeSuggestionIndices[ type ] = -1;
// Remove blur event listener when suggestions are hidden
if ( blurHandlers[ type ] ) {
addressInput.removeEventListener(
'blur',
blurHandlers[ type ]
);
delete blurHandlers[ type ];
}
}
/**
* Helper function to set field value and trigger events.
* @param input {HTMLInputElement} The input element to set the value for.
* @param value {string} The value to set.
*/
const setFieldValue = ( input, value ) => {
if ( input ) {
input.value = value;
input.dispatchEvent( new Event( 'change' ) );
// Also trigger Select2 update if it's a Select2 field.
if (
window.jQuery &&
window
.jQuery( input )
.hasClass( 'select2-hidden-accessible' )
) {
window.jQuery( input ).trigger( 'change' );
}
}
};
/**
* Select an address from the suggestions list and submit it to the provider's `select` method.
* @param type {string} The address type ('billing' or 'shipping').
* @param addressId {string} The ID of the address to select.
* @return {Promise<void>}
*/
async function selectAddress( type, addressId ) {
let addressData;
try {
addressData =
await window.wc.addressAutocomplete.activeProvider[
type
].select( addressId );
} catch ( error ) {
console.error(
'Error selecting address from provider',
window.wc.addressAutocomplete.activeProvider[ type ].id,
error
);
return; // Exit early if address selection fails.
}
if (
typeof addressData !== 'object' ||
addressData === null ||
! addressData
) {
// Return without setting the address since response was invalid.
return;
}
if ( addressData.country ) {
setFieldValue(
addressInputs[ type ][ 'country' ],
addressData.country
);
}
if ( addressData.address_1 ) {
setFieldValue(
addressInputs[ type ][ 'address_1' ],
addressData.address_1
);
}
// Note: Passing an invalid ID to clearTimeout() silently does nothing; no exception is thrown.
if ( addressSelectionTimeout ) {
clearTimeout( addressSelectionTimeout );
}
addressSelectionTimeout = setTimeout( function () {
// Cache address fields again as they may have updated following the country change.
cacheAddressFields( type );
// Set all available fields.
// Only set fields if the address data property exists and has a value.
if ( addressData.address_2 ) {
setFieldValue(
addressInputs[ type ][ 'address_2' ],
addressData.address_2
);
}
if ( addressData.city ) {
setFieldValue(
addressInputs[ type ][ 'city' ],
addressData.city
);
}
if ( addressData.postcode ) {
setFieldValue(
addressInputs[ type ][ 'postcode' ],
addressData.postcode
);
}
if ( addressData.state ) {
setFieldValue(
addressInputs[ type ][ 'state' ],
addressData.state
);
}
}, 50 );
}
/**
* Set the active suggestion in the suggestions list, highlights it.
* @param type {string} The address type ('billing' or 'shipping').
* @param index {number} The index of the suggestion to set as active.
*/
function setActiveSuggestion( type, index ) {
// Check if the address section exists (shipping may be disabled/hidden)
if (
! addressInputs[ type ] ||
! addressInputs[ type ][ 'address_1' ]
) {
return;
}
if ( ! suggestionsLists[ type ] ) {
return;
}
const suggestionsList = suggestionsLists[ type ];
const addressInput = addressInputs[ type ][ 'address_1' ];
const activeLi = suggestionsList.querySelector( 'li.active' );
if ( activeLi ) {
activeLi.classList.remove( 'active' );
activeLi.setAttribute( 'aria-selected', 'false' );
}
const newActiveLi = suggestionsList.querySelector(
`li#suggestion-item-${ type }-${ index }`
);
if ( newActiveLi ) {
newActiveLi.classList.add( 'active' );
newActiveLi.setAttribute( 'aria-selected', 'true' );
addressInput.setAttribute(
'aria-activedescendant',
newActiveLi.id
);
activeSuggestionIndices[ type ] = index;
}
}
// Initialize event handlers for each address type.
addressTypes.forEach( ( type ) => {
const addressInput = addressInputs[ type ][ 'address_1' ];
const countryInput = addressInputs[ type ][ 'country' ];
if ( addressInput && countryInput ) {
addressInput.addEventListener( 'input', function () {
displaySuggestions( this.value, countryInput.value, type );
} );
addressInput.addEventListener( 'keydown', async function ( e ) {
// Check if suggestions exist before accessing them
if (
! suggestionsLists[ type ] ||
! suggestionsContainers[ type ]
) {
return;
}
const items =
suggestionsLists[ type ].querySelectorAll( 'li' );
if (
items.length === 0 ||
suggestionsContainers[ type ].style.display === 'none'
) {
return;
}
let newIndex = activeSuggestionIndices[ type ];
if ( e.key === 'ArrowDown' ) {
e.preventDefault();
newIndex =
( activeSuggestionIndices[ type ] + 1 ) %
items.length;
setActiveSuggestion( type, newIndex );
} else if ( e.key === 'ArrowUp' ) {
e.preventDefault();
newIndex =
( activeSuggestionIndices[ type ] -
1 +
items.length ) %
items.length;
setActiveSuggestion( type, newIndex );
} else if ( e.key === 'Enter' ) {
if ( activeSuggestionIndices[ type ] > -1 ) {
e.preventDefault();
const selectedItem = suggestionsLists[
type
].querySelector(
`li#suggestion-item-${ type }-${ activeSuggestionIndices[ type ] }`
);
// Hide suggestions immediately for better UX.
hideSuggestions( type );
enableBrowserAutofill( addressInput );
await selectAddress(
type,
selectedItem.dataset.id
);
}
} else if ( e.key === 'Escape' ) {
hideSuggestions( type );
enableBrowserAutofill( addressInput );
}
} );
}
} );
// Hide suggestions when clicking outside.
document.addEventListener( 'click', function ( event ) {
addressTypes.forEach( ( type ) => {
// Check if the address section exists before accessing elements
if (
! addressInputs[ type ] ||
! addressInputs[ type ][ 'address_1' ]
) {
return;
}
if ( ! suggestionsContainers[ type ] ) {
return;
}
const target = event.target;
if (
target !== suggestionsContainers[ type ] &&
! suggestionsContainers[ type ].contains( target ) &&
target !== addressInputs[ type ][ 'address_1' ]
) {
hideSuggestions( type );
}
} );
} );
} );
} )();