991 lines
29 KiB
JavaScript
991 lines
29 KiB
JavaScript
/**
|
|
* 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<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.setAttribute( 'aria-label', suggestion.label );
|
|
li.id = `suggestion-item-${ type }-${ index }`;
|
|
li.dataset.id = suggestion.id;
|
|
|
|
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 );
|
|
} );
|
|
|
|
// Update branding HTML content and make sure it's visible.
|
|
// Sanitize the HTML using DOMPurify if available
|
|
if (
|
|
typeof DOMPurify !== 'undefined' &&
|
|
typeof DOMPurify.sanitize === 'function'
|
|
) {
|
|
// Add branding HTML if available from the active provider.
|
|
const activeProvider =
|
|
window.wc.addressAutocomplete.activeProvider[
|
|
type
|
|
];
|
|
if ( activeProvider && activeProvider.id ) {
|
|
const serverProvider =
|
|
window.wc.addressAutocomplete.getServerProvider(
|
|
activeProvider.id
|
|
);
|
|
const brandingHtml =
|
|
serverProvider &&
|
|
typeof serverProvider.branding_html === 'string'
|
|
? serverProvider.branding_html.trim()
|
|
: '';
|
|
if ( brandingHtml ) {
|
|
// Check if branding element already exists.
|
|
let brandingElement =
|
|
suggestionsContainer.querySelector(
|
|
'.woocommerce-address-autocomplete-branding'
|
|
);
|
|
if ( ! brandingElement ) {
|
|
brandingElement =
|
|
document.createElement( 'div' );
|
|
brandingElement.className =
|
|
'woocommerce-address-autocomplete-branding';
|
|
suggestionsContainer.appendChild(
|
|
brandingElement
|
|
);
|
|
}
|
|
// Allow common HTML tags and attributes for branding
|
|
const sanitizedHtml = DOMPurify.sanitize(
|
|
serverProvider.branding_html,
|
|
{
|
|
ALLOWED_TAGS: [
|
|
'img',
|
|
'span',
|
|
'div',
|
|
'a',
|
|
'b',
|
|
'i',
|
|
'em',
|
|
'strong',
|
|
'br',
|
|
],
|
|
ALLOWED_ATTR: [
|
|
'href',
|
|
'target',
|
|
'rel',
|
|
'src',
|
|
'alt',
|
|
'style',
|
|
'class',
|
|
'id',
|
|
'width',
|
|
'height',
|
|
],
|
|
ALLOW_DATA_ATTR: false,
|
|
}
|
|
);
|
|
brandingElement.innerHTML = sanitizedHtml;
|
|
brandingElement.style.display = 'flex';
|
|
brandingElement.removeAttribute(
|
|
'aria-hidden'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
disableBrowserAutofill( addressInput );
|
|
suggestionsContainer.style.display = 'block';
|
|
suggestionsContainer.style.marginTop =
|
|
addressInputs[ type ][ 'address_1' ].offsetHeight +
|
|
'px';
|
|
addressInput.setAttribute( 'aria-expanded', 'true' );
|
|
suggestionsList.id = `address_suggestions_${ type }_list`;
|
|
addressInput.setAttribute(
|
|
'aria-controls',
|
|
`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 = '';
|
|
|
|
// Hide branding element but keep it in DOM (will be removed on country change).
|
|
const brandingElement = suggestionsContainer.querySelector(
|
|
'.woocommerce-address-autocomplete-branding'
|
|
);
|
|
if ( brandingElement ) {
|
|
brandingElement.style.display = 'none';
|
|
brandingElement.setAttribute( 'aria-hidden', 'true' );
|
|
}
|
|
|
|
suggestionsContainer.style.display = 'none';
|
|
addressInput.setAttribute( 'aria-expanded', 'false' );
|
|
addressInput.removeAttribute( 'aria-activedescendant' );
|
|
addressInput.removeAttribute( 'aria-controls' );
|
|
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;
|
|
}
|
|
|
|
// Check if addressInputs exists for this type
|
|
if ( ! addressInputs[ type ] ) {
|
|
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 );
|
|
|
|
// Check if addressInputs exists for this type after re-caching
|
|
if ( ! addressInputs[ type ] ) {
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
);
|
|
} else {
|
|
// Clear address_2 if not provided in address data.
|
|
const addr2El = addressInputs[ type ][ 'address_2' ];
|
|
if ( addr2El && addr2El.value ) {
|
|
setFieldValue( addr2El, '' );
|
|
}
|
|
}
|
|
if ( addressData.city ) {
|
|
setFieldValue(
|
|
addressInputs[ type ][ 'city' ],
|
|
addressData.city
|
|
);
|
|
} else {
|
|
// Clear city if not provided in address data.
|
|
const cityEl = addressInputs[ type ][ 'city' ];
|
|
if ( cityEl && cityEl.value ) {
|
|
setFieldValue( cityEl, '' );
|
|
}
|
|
}
|
|
if ( addressData.postcode ) {
|
|
setFieldValue(
|
|
addressInputs[ type ][ 'postcode' ],
|
|
addressData.postcode
|
|
);
|
|
} else {
|
|
// Clear postcode if not provided in address data.
|
|
const postcodeEl = addressInputs[ type ][ 'postcode' ];
|
|
if ( postcodeEl && postcodeEl.value ) {
|
|
setFieldValue( postcodeEl, '' );
|
|
}
|
|
}
|
|
if ( addressData.state ) {
|
|
setFieldValue(
|
|
addressInputs[ type ][ 'state' ],
|
|
addressData.state
|
|
);
|
|
} else {
|
|
// Clear state if not provided in address data.
|
|
const stateEl = addressInputs[ type ][ 'state' ];
|
|
if ( stateEl && stateEl.value ) {
|
|
setFieldValue( stateEl, '' );
|
|
}
|
|
}
|
|
}, 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 ) => {
|
|
// Check if addressInputs exists for this type
|
|
if ( ! addressInputs[ type ] ) {
|
|
return;
|
|
}
|
|
const addressInput = addressInputs[ type ][ 'address_1' ];
|
|
const countryInput = addressInputs[ type ][ 'country' ];
|
|
if ( addressInput && countryInput ) {
|
|
addressInput.addEventListener( 'input', function () {
|
|
// Unset any active suggestion when user types
|
|
if ( suggestionsLists[ type ] ) {
|
|
const activeLi =
|
|
suggestionsLists[ type ].querySelector(
|
|
'li.active'
|
|
);
|
|
if ( activeLi ) {
|
|
activeLi.classList.remove( 'active' );
|
|
activeLi.setAttribute(
|
|
'aria-selected',
|
|
'false'
|
|
);
|
|
}
|
|
addressInput.removeAttribute(
|
|
'aria-activedescendant'
|
|
);
|
|
activeSuggestionIndices[ type ] = -1;
|
|
}
|
|
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 ] }`
|
|
);
|
|
if (
|
|
! selectedItem ||
|
|
! selectedItem.dataset ||
|
|
! selectedItem.dataset.id
|
|
) {
|
|
// The selected item was invalid, hide suggestions and re-enable autofill.
|
|
hideSuggestions( type );
|
|
enableBrowserAutofill( addressInput );
|
|
return;
|
|
}
|
|
// Hide suggestions immediately for better UX.
|
|
hideSuggestions( type );
|
|
enableBrowserAutofill( addressInput );
|
|
await selectAddress(
|
|
type,
|
|
selectedItem.dataset.id
|
|
);
|
|
// Return focus to the address input after selection
|
|
addressInput.focus();
|
|
}
|
|
} 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 );
|
|
// Restore native autofill after manual dismissal.
|
|
if (
|
|
addressInputs[ type ] &&
|
|
addressInputs[ type ][ 'address_1' ]
|
|
) {
|
|
enableBrowserAutofill(
|
|
addressInputs[ type ][ 'address_1' ],
|
|
false
|
|
);
|
|
}
|
|
}
|
|
} );
|
|
} );
|
|
} );
|
|
} )();
|
|
}
|