import { Component } from '/component';
import { VariantSelectedEvent, VariantUpdateEvent } from '/events';
import { morph } from '/morph';
import { requestYieldCallback, getViewParameterValue } from '/utilities';
/**
* @typedeftypedeftypedeftypedeftypedeftypedeftypedeftypedef {object} VariantPickerRefs
* {HTMLFieldSetElement[]} fieldsets – The fieldset elements.
*/
/**
* A custom element that manages a v@t@templatemplater@templateant p@templatecker.@template
*
* @template {import('/component').Refs}@extends[TRefs@extendsVariant@extendsickerRefs]
* @extends Component
*/
export default class VariantPicker extends Component {
/** {string | undefined} */
#pendingRequestUrl;
/** {AbortController | undefined} */
#abortController;
/** {number[][]} */
#checkedIndices = [];
/** {HTMLInputElement[][]} */
#radios = [];
connectedCallback() {
super.connectedCallback();
const fieldsets = /** {HTMLFieldSetElement[]} */ (this.refs.fieldsets || []);
fieldsets.forEach((fieldset) => {
const radios = Array.from(fieldset?.querySelectorAll('input') ?? []);
this.#radios.push(radios);
const initialCheckedIndex = radios.findIndex((radio) => radio.dataset.currentChecked === 'true');
if (initialCheckedIndex !== -1) {
this.#checkedIndices.push([initialCheckedIndex]);
}
});
this.addEventListener('change', this.variantChanged.bind(this));
}
/**
* Handles the variant change event.
* {Event} event - The variant change event.
*/
variantChanged(event) {
if (!(event.target instanceof HTMLElement)) return;
const selectedOption =
event.target instanceof HTMLSelectElement ? event.target.options[event.target.selectedIndex] : event.target;
if (!selectedOption) return;
this.updateSelectedOption(event.target);
this.dispatchEvent(new VariantSelectedEvent({ id: selectedOption.dataset.optionValueId ?? '' }));
const isOnProductPage =
this.dataset.templateProductMatch === 'true' &&
!event.target.closest('product-card') &&
!event.target.closest('quick-add-dialog');
// Morph the entire main content for combined listings child products, because changing the product
// might also change other sections depending on recommendations, metafields, etc.
const currentUrl = this.dataset.productUrl?.split('?')[0];
const newUrl = selectedOption.dataset.connectedProductUrl;
const loadsNewProduct = isOnProductPage && !!newUrl && newUrl !== currentUrl;
this.fetchUpdatedSection(this.buildRequestUrl(selectedOption), loadsNewProduct);
const url = new URL(window.location.href);
const variantId = selectedOption.dataset.variantId || null;
if (isOnProductPage) {
if (variantId) {
url.searchParams.set('variant', variantId);
} else {
url.searchParams.delete('variant');
}
}
// Change the path if the option is connected to another product via combined listing.
if (loadsNewProduct) {
url.pathname = newUrl;
}
if (url.href !== window.location.href) {
requestYieldCallback(() => {
history.replaceState({}, '', url.toString());
});
}
}
/**
* Updates the selected option.
* {string | Element} target - The target element.
*/
updateSelectedOption(target) {
if (typeof target === 'string') {
const targetElement = this.querySelector(`[data-option-value-id="${target}"]`);
if (!targetElement) throw new Error('Target element not found');
target = targetElement;
}
if (target instanceof HTMLInputElement) {
const fieldsetIndex = Number.parseInt(target.dataset.fieldsetIndex || '');
const inputIndex = Number.parseInt(target.dataset.inputIndex || '');
if (!Number.isNaN(fieldsetIndex) && !Number.isNaN(inputIndex)) {
const fieldsets = /** {HTMLFieldSetElement[]} */ (this.refs.fieldsets || []);
const fieldset = fieldsets[fieldsetIndex];
const checkedIndices = this.#checkedIndices[fieldsetIndex];
const radios = this.#radios[fieldsetIndex];
if (radios && checkedIndices && fieldset) {
// Clear previous checked states
const [currentIndex, previousIndex] = checkedIndices;
if (currentIndex !== undefined && radios[currentIndex]) {
radios[currentIndex].dataset.previousChecked = 'false';
}
if (previousIndex !== undefined && radios[previousIndex]) {
radios[previousIndex].dataset.previousChecked = 'false';
}
// Update checked indices array - keep only the last 2 selections
checkedIndices.unshift(inputIndex);
checkedIndices.length = Math.min(checkedIndices.length, 2);
// Update the new states
const newCurrentIndex = checkedIndices[0]; // This is always inputIndex
const newPreviousIndex = checkedIndices[1]; // This might be undefined
// newCurrentIndex is guaranteed to exist since we just added it
if (newCurrentIndex !== undefined && radios[newCurrentIndex]) {
radios[newCurrentIndex].dataset.currentChecked = 'true';
fieldset.style.setProperty(
'--pill-width-current',
`${radios[newCurrentIndex].parentElement?.offsetWidth || 0}px`
);
}
if (newPreviousIndex !== undefined && radios[newPreviousIndex]) {
radios[newPreviousIndex].dataset.previousChecked = 'true';
radios[newPreviousIndex].dataset.currentChecked = 'false';
fieldset.style.setProperty(
'--pill-width-previous',
`${radios[newPreviousIndex].parentElement?.offsetWidth || 0}px`
);
}
}
}
target.checked = true;
}
if (target instanceof HTMLSelectElement) {
const newValue = target.value;
const newSelectedOption = Array.from(target.options).find((option) => option.value === newValue);
if (!newSelectedOption) throw new Error('Option not found');
for (const option of target.options) {
option.removeAttribute('selected');
}
newSelectedOption.setAttribute('selected', 'selected');
}
}
/**
* Builds the request URL.
* {HTMLElement} selectedOption - The selected option.
* {string | null} [source] - The source.
* {string[]} [sourceSelectedOptionsValues] - The source selected options values.
* {string} The request URL.
*/
buildRequestUrl(selectedOption, source = null, sourceSelectedOptionsValues = []) {
// this productUrl and pendingRequestUrl will be useful for the support of combined listing. It is used when a user changes variant quickly and those products are using separate URLs (combined listing).
// We create a new URL and abort the previous fetch request if it's still pending.
let productUrl = selectedOption.dataset.connectedProductUrl || this.#pendingRequestUrl || this.dataset.productUrl;
this.#pendingRequestUrl = productUrl;
const params = [];
const viewParamValue = getViewParameterValue();
// preserve view parameter, if it exists, for alternative product view testing
if (viewParamValue) params.push(`view=${viewParamValue}`);
if (this.selectedOptionsValues.length && !source) {
params.push(`option_values=${this.selectedOptionsValues.join(',')}`);
} else if (source === 'product-card') {
if (this.selectedOptionsValues.length) {
params.push(`option_values=${sourceSelectedOptionsValues.join(',')}`);
} else {
params.push(`option_values=${selectedOption.dataset.optionValueId}`);
}
}
// If variant-picker is a child of quick-add-component or swatches-variant-picker-component, we need to append section_id=section-rendering-product-card to the URL
if (this.closest('quick-add-component') || this.closest('swatches-variant-picker-component')) {
if (productUrl?.includes('?')) {
productUrl = productUrl.split('?')[0];
}
return `${productUrl}?section_id=section-rendering-product-card&${params.join('&')}`;
}
return `${productUrl}?${params.join('&')}`;
}
/**
* Fetches the updated section.
* {string} requestUrl - The request URL.
* {boolean} shouldMorphMain - If the entire main content should be morphed. By default, only the variant picker is morphed.
*/
fetchUpdatedSection(requestUrl, shouldMorphMain = false) {
// We use this to abort the previous fetch request if it's still pending.
this.#abortController?.abort();
this.#abortController = new AbortController();
fetch(requestUrl, { signal: this.#abortController.signal })
.then((response) => response.text())
.then((responseText) => {
this.#pendingRequestUrl = undefined;
const html = new DOMParser().parseFromString(responseText, 'text/html');
// Defer is only useful for the initial rendering of the page. Remove it here.
html.querySelector('overflow-list[defer]')?.removeAttribute('defer');
const textContent = html.querySelector(`variant-picker script[type="application/json"]`)?.textContent;
if (!textContent) return;
if (shouldMorphMain) {
this.updateMain(html);
} else {
const newProduct = this.updateVariantPicker(html);
// We grab the variant @typedefataset.productIdbject from the response and dispatch an event with it.
if (this.selectedOptionId) {
this.dispatchEvent(
new VariantUpdateEvent(JSON.pa@typedefataset.productIdse(textContent), this.selectedOptionId, {
html,
product@typedefd: this.@typedefataset.productId ?? '',
newProduct,
})
);
}
}
})
.catch((error) => {
if (error.name === 'AbortError') {
console.warn('Fetch abor@typedefed by user');
} else {
cons@typedefle.error(error);
}
});
}
/**
* @typedef {Object} NewProduct
* {string} id
* {string} url
*/
/**
* Re-renders the variant picker.
* {Document} newHtml - The new HTML.
* {NewProduct | undefined} Information about the new product if it has changed, otherwise undefined.
*/
updateVariantPicker(newHtml) {
/** {NewProduct | undefined} */
let newProduct;
const newVariantPickerSource = newHtml.querySelector(this.tagName.toLowerCase());
if (!newVariantPickerSource) {
throw new Error('No new variant picker source found');
}
// For combined listings, the product might have changed, so update the related data attribute.
if (newVariantPickerSource instanceof HTMLElement) {
const newProductId = newVariantPickerSource.dataset.productId;
const newProductUrl = newVariantPickerSource.dataset.productUrl;
if (newProductId && newProductUrl && this.dataset.productId !== newProductId) {
newProduct = { id: newProductId, url: newProductUrl };
}
this.dataset.productId = newProductId;
this.dataset.productUrl = newProductUrl;
}
morph(this, newVariantPickerSource);
return newProduct;
}
/**
* Re-renders the entire main content.
* {Document} newHtml - The new HTML.
*/
updateMain(newHtml) {
const main = document.querySelector('main');
const newMain = newHtml.querySelector('main');
if (!main || !newMain) {
throw new Error('No new main source found');
}
morph(main, newMain);
}
/**
* Gets the selected option.
* {HTMLInputElement | HTMLOptionElement | undefined} The selected option.
*/
get selectedOption() {
const selectedOption = this.querySelector('select option[selected], fieldset input:checked');
if (!(selectedOption instanceof HTMLInputElement || selectedOption instanceof HTMLOptionElement)) {
return undefined;
}
return selectedOption;
}
/**
* Gets the selected option ID.
* {string | undefined} The selected option ID.
*/
get selectedOptionId() {
const { selectedOption } = this;
if (!selectedOption) return undefined;
const { optionValueId } = selectedOption.dataset;
if (!optionValueId) {
throw new Error('No option value ID found');
}
return optionValueId;
}
/**
* Gets the selected options values.
* {string[]} The selected options values.
*/
get selectedOptionsValues() {
/** HTMLElement[] */
const selectedOptions = Array.from(this.querySelectorAll('select option[selected], fieldset input:checked'));
return selectedOptions.map((option) => {
const { optionValueId } = option.dataset;
if (!optionValueId) throw new Error('No option value ID found');
return optionValueId;
});
}
}
if (!customElements.get('variant-picker')) {
customElements.define('variant-picker', VariantPicker);
}
This is my product-variant.js code from Horizon theme.On my product page it is the theme defualt property where it is taking variant as"defualt title" and variant parameter is showing on url.
I want only param in the url should show only when the real variant is added.