Pro Pant 2.0 - Black - Taper Fit
Regular price
$87.50 USD
Unit price
/* eslint-disable max-len */
/**
* Dependencies:
* - Custom select component
* - Fetch cache (theme.fetchCache)
* - Quickbuy content transformer (theme.quickBuy.convertToQuickBuyContent)
*
* Required translation strings:
* - noStock
* - noVariant
*/
if (!customElements.get('variant-picker')) {
class VariantPicker extends HTMLElement {
constructor() {
super();
this.quickBuyContainer = this.closest('.js-quickbuy');
this.featuredProductContainer = this.closest('.cc-featured-product');
this.section = this.quickBuyContainer
|| this.featuredProductContainer
|| this.closest('.shopify-section');
this.optionSelectors = this.querySelectorAll(
'.option-selector:not(.option-selector--custom):not(.option-selector--composite-generated)'
);
this.productAvailable = this.classList.contains('variant-picker--product-available');
this.variant = this.getVariantData();
this.compositeSelectionState = {};
if (this.dataset.compositeSelectionState) {
try {
this.compositeSelectionState = JSON.parse(this.dataset.compositeSelectionState) || {};
} catch (error) {
this.compositeSelectionState = {};
}
}
// Track interaction independently for each composite option.
this.compositeInteractionState = {};
if (this.dataset.compositeInteractionState) {
try {
this.compositeInteractionState = JSON.parse(this.dataset.compositeInteractionState) || {};
} catch (error) {
this.compositeInteractionState = {};
}
}
this.compositeUserInteracted = Object.values(this.compositeInteractionState).some(Boolean);
// A saved compositeSelectionState means Shopify has just replaced the
// section. initCompositeSelectors() must not overwrite that state with
// the blank controls it creates before syncCompositeSelectorsFromNative()
// has a chance to restore them.
this.restoringCompositeState = Object.keys(this.compositeSelectionState).length > 0;
this.variantRequestId = 0;
// High-variant support is only used when Shopify's Liquid
// product.variants collection is over its 250-variant limit.
this.highVariantDataLoaded = false;
this.highVariantDataLoading = false;
this.addListeners();
this.updateVariantContent();
if (!this.quickBuyContainer && !this.featuredProductContainer) {
setTimeout(this.applySearchParams.bind(this), 0);
}
this.setAttribute('loaded', '');
}
addListeners() {
// The dynamic variant content used by the theme can be replaced without
// constructing a new , so refresh this NodeList here.
this.optionSelectors = this.querySelectorAll(
'.option-selector:not(.option-selector--custom):not(.option-selector--composite-generated)'
);
this.initCompositeSelectors();
// Do not change the normal v13 path for products with <=250 variants.
// Larger products are augmented after the existing controls are built.
this.loadHighVariantDataIfNeeded();
// Composite selectors are an alternate UI and must start unselected on
// the initial page load. When Shopify replaces the picker after a real
// composite selection, the selection state is carried in data attributes;
// in that case we MUST NOT clear the newly inserted native selector.
if (!this.compositeInitialised) {
this.compositeInitialised = true;
if (!Object.values(this.compositeInteractionState).some(Boolean)) {
this.clearAllCompositeSelections();
}
}
this.boundHandleVariantChange = this.handleVariantChange.bind(this);
this.addEventListener('change', this.boundHandleVariantChange);
this.boundHandleLabelMouseEnter = this.handleLabelMouseEnter.bind(this);
this.querySelectorAll('.opt-label, .custom-select__option').forEach((el) => {
el.addEventListener('mouseenter', this.boundHandleLabelMouseEnter);
el.addEventListener('touchstart', this.boundHandleLabelMouseEnter, { passive: true, once: true });
el.addEventListener('mouseleave', VariantPicker.handleLabelMouseLeave);
});
}
/**
* Builds the additional human-friendly dropdowns for composite Shopify
* option values. The real Shopify selector remains in the DOM but hidden.
*
* A composite option looks like:
* Name: Make--Model--Bed Length
* Value: Chevy--Silverado 1500--6'
*
* The generated controls are only a UI layer. When all component controls
* are selected, the exact original Shopify value is selected on the
* hidden custom-select, allowing the rest of the theme to work normally.
*/
/**
* Augment the existing v13 variant matrix for products with more than
* Shopify's 250-variant Liquid limit.
*
* The original Liquid JSON remains the initial data source. This method
* only runs for >250 variants, fetches the complete variant connection,
* appends missing variants, and then re-runs the existing v13 filtering.
*/
async loadHighVariantDataIfNeeded() {
const variantCount = Number(this.dataset.variantCount || 0);
if (!Number.isFinite(variantCount) || variantCount <= 250) return;
if (this.highVariantDataLoaded || this.highVariantDataLoading) return;
const handle = this.dataset.productHandle || '';
if (!handle) {
console.warn('High-variant composite picker: product handle is missing.');
return;
}
this.highVariantDataLoading = true;
try {
const query = `
query ProductVariants($handle: String!, $cursor: String) {
productByHandle(handle: $handle) {
variants(first: 250, after: $cursor) {
nodes {
id
availableForSale
selectedOptions {
name
value
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
const allVariants = [];
let cursor = null;
let hasNextPage = true;
while (hasNextPage) {
const response = await fetch('/api/2026-07/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
query,
variables: { handle, cursor }
})
});
if (!response.ok) {
throw new Error(`Storefront API returned HTTP ${response.status}`);
}
const payload = await response.json();
if (payload.errors?.length) {
throw new Error(payload.errors.map((error) => error.message).join('; '));
}
const connection = payload.data?.productByHandle?.variants;
if (!connection) {
throw new Error('Storefront API did not return product variants.');
}
allVariants.push(...(connection.nodes || []));
hasNextPage = Boolean(connection.pageInfo?.hasNextPage);
cursor = connection.pageInfo?.endCursor || null;
if (hasNextPage && !cursor) {
throw new Error('Storefront API returned another page without a cursor.');
}
}
if (!allVariants.length) {
throw new Error('Storefront API returned no variants.');
}
const completeVariants = allVariants.map((variant) => ({
id: String(variant.id || '').split('/').pop(),
available: Boolean(variant.availableForSale),
options: (variant.selectedOptions || []).map((option) => option.value)
}));
const existingIds = new Set(
(this.compositeVariants || []).map((variant) => String(variant.id))
);
const additionalVariants = completeVariants.filter(
(variant) => variant.id && !existingIds.has(variant.id)
);
this.compositeVariants = [
...(this.compositeVariants || []),
...additionalVariants
];
this.highVariantDataLoaded = true;
// Reuse the existing v13 filtering/selection logic.
this.querySelectorAll(
'.option-selector[data-composite-option="true"]'
).forEach((nativeSelector) => {
this.updateCompositeSelectorsFor(nativeSelector);
});
this.autoSelectForcedCompositeOptions();
} catch (error) {
// Keep the original v13 first-250 behavior if the API is unavailable.
console.warn(
'Unable to load variants beyond Shopify Liquid’s 250-variant limit for the composite picker.',
error
);
} finally {
this.highVariantDataLoading = false;
}
}
initCompositeSelectors() {
const variantDataElement = this.querySelector('[data-composite-variant-data]');
if (!variantDataElement) return;
let variants;
try {
variants = JSON.parse(variantDataElement.textContent);
} catch (error) {
console.warn('Unable to parse composite variant data.', error);
return;
}
this.compositeVariants = Array.isArray(variants) ? variants : [];
// Style invalid composite choices without affecting the theme's native
// custom-select styling. The invalid choices remain in the list so the
// customer can see the complete option set.
if (!this.querySelector('style[data-composite-option-styles]')) {
const style = document.createElement('style');
style.dataset.compositeOptionStyles = 'true';
style.textContent = `
.composite-option-select option.composite-option-invalid {
color: #b85c5c;
background-color: #fdeaea;
}
`;
this.appendChild(style);
}
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
if (nativeSelector.dataset.compositeInitialized === 'true') return;
const delimiter = nativeSelector.dataset.compositeDelimiter || '--';
const nativeOptionName = nativeSelector.dataset.option || '';
const firstNativeValue = nativeSelector.querySelector('.js-option[data-value]')?.dataset.value || '';
if (!firstNativeValue) return;
// The Liquid template uses -- in values even when the abbreviated
// Year-Make-Model name uses single hyphens.
const partNames = nativeOptionName.includes('--')
? nativeOptionName.split('--')
: nativeOptionName.split('-');
const nativeOptions = Array.from(
nativeSelector.querySelectorAll('.js-option[data-value]:not([data-value=""])')
).map((option) => ({
value: option.dataset.value,
valueId: option.dataset.valueId,
parts: option.dataset.value.split('--')
}));
if (!partNames.length || nativeOptions.some((option) => option.parts.length !== partNames.length)) {
return;
}
const compositeContainer = document.createElement('div');
compositeContainer.className = 'composite-option-pickers';
compositeContainer.dataset.compositeFor = nativeSelector.dataset.index;
partNames.forEach((partName, partIndex) => {
const wrapper = document.createElement('div');
wrapper.className = 'option-selector option-selector--composite-generated';
wrapper.dataset.compositeGenerated = 'true';
wrapper.dataset.compositeIndex = nativeSelector.dataset.index;
wrapper.dataset.compositePartIndex = partIndex;
const label = document.createElement('label');
label.className = 'label';
label.textContent = partName;
const select = document.createElement('select');
select.className = 'select composite-option-select custom-select__btn input items-center';
select.dataset.compositeIndex = nativeSelector.dataset.index;
select.dataset.compositePartIndex = partIndex;
select.setAttribute('aria-label', partName);
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = this.dataset.placeholderText || 'Please select';
select.appendChild(placeholder);
wrapper.appendChild(label);
wrapper.appendChild(select);
compositeContainer.appendChild(wrapper);
});
nativeSelector.after(compositeContainer);
nativeSelector.classList.add('option-selector--composite-native');
nativeSelector.setAttribute('aria-hidden', 'true');
nativeSelector.style.display = 'none';
nativeSelector.dataset.compositeInitialized = 'true';
nativeSelector.querySelectorAll('.js-option').forEach((option) => {
option.dataset.compositeOriginalValue = option.dataset.value || '';
});
this.updateCompositeSelectorsFor(nativeSelector);
});
}
/**
* Reset every generated composite selector and its hidden Shopify selector.
* This is deliberately used only during initial setup; after that,
* selections are controlled by the customer.
*/
clearAllCompositeSelections() {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (container) {
container.querySelectorAll('.composite-option-select').forEach((select) => {
select.value = '';
});
}
this.compositeSelectionState[nativeSelector.dataset.index] = [];
this.clearCompositeNativeSelection(nativeSelector);
this.updateCompositeSelectorsFor(nativeSelector);
});
}
/**
* Returns the component parts for a Shopify option value.
*/
getCompositeParts(nativeValue, delimiter) {
// Shopify values always use the double-hyphen delimiter. The delimiter
// argument is retained so the generated control can document which
// name syntax activated the feature.
return String(nativeValue || '').split('--');
}
/**
* Tests whether a generated component selection matches a native value.
* Year is special: selecting 2005 matches a native value such as
* "2000-2008".
*/
compositePartMatches(partName, selectedValue, nativePart) {
if (!selectedValue) return true;
if (partName.trim().toLowerCase() === 'year') {
const rangeMatch = String(nativePart).trim().match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const year = Number(selectedValue);
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
return Number.isInteger(year) && year >= Math.min(start, end) && year <= Math.max(start, end);
}
}
return nativePart === selectedValue;
}
/**
* Year dropdowns may temporarily display the exact Shopify range that was
* selected by the variant refresh. This is presentation-only state; it is
* not a real generated year selection.
*/
isCompositeYearRangePlaceholder(value) {
return typeof value === 'string' && value.startsWith('__native_year_range__:');
}
getCompositeYearRangeFromPlaceholder(value) {
if (!this.isCompositeYearRangePlaceholder(value)) return '';
return value.slice('__native_year_range__:'.length);
}
/**
* Gets the currently selected generated values for one composite option.
*/
getCompositeSelection(nativeSelector) {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (!container) return [];
return Array.from(container.querySelectorAll('.composite-option-select')).map((select) => select.value || null);
}
/**
* Determines whether a Shopify variant is compatible with the selections
* currently made in the generated controls.
*/
variantMatchesCompositeSelections(variant, selections, compositeSelectorIndex) {
const nativeOptionIndex = Number(compositeSelectorIndex) - 1;
const nativeValue = variant?.options?.[nativeOptionIndex];
if (nativeValue == null) return false;
const nativeParts = this.getCompositeParts(nativeValue, '');
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(String(compositeSelectorIndex))}"]`
);
const selects = container ? Array.from(container.querySelectorAll('.composite-option-select')) : [];
return selections.every((selectedValue, partIndex) => {
if (!selectedValue) return true;
const partName = selects[partIndex]?.getAttribute('aria-label') || '';
return this.compositePartMatches(partName, selectedValue, nativeParts[partIndex]);
});
}
/**
* Updates every generated dropdown so it contains only values that can
* still lead to a real Shopify variant.
*/
updateCompositeSelectorsFor(nativeSelector) {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (!container || !this.compositeVariants) return;
const selects = Array.from(container.querySelectorAll('.composite-option-select'));
const nativeOptions = Array.from(
nativeSelector.querySelectorAll('.js-option[data-value]:not([data-value=""])')
).map((option) => ({
value: option.dataset.value,
parts: this.getCompositeParts(option.dataset.value, nativeSelector.dataset.compositeDelimiter)
}));
const currentSelections = selects.map((select) => {
const value = select.value || null;
// A native year-range placeholder is informational only. It must not
// constrain the sparse-tree filtering as though it were a real year.
return this.isCompositeYearRangePlaceholder(value) ? null : value;
});
selects.forEach((select, partIndex) => {
const selectedOtherParts = currentSelections.slice();
selectedOtherParts[partIndex] = null;
const nativeOptionIndex = Number(nativeSelector.dataset.index) - 1;
const otherNativeSelections = Array.from(this.optionSelectors).map((selector, selectorIndex) => {
if (selector === nativeSelector) return null;
// Ignore hidden Shopify selections for other composite groups. Their
// native values may be Shopify's automatic first variant. The
// generated controls below are the authoritative composite state.
if (selector.dataset.compositeOption === 'true') return null;
if (selector.dataset.selectorType === 'dropdown') {
const selected = selector.querySelector('.custom-select__option[aria-selected="true"]');
return selected && selected.dataset.value !== '' ? selected.dataset.value : null;
}
const selected = selector.querySelector('input:checked');
return selected ? selected.value : null;
});
const possibleNativeValues = nativeOptions.filter((nativeOption) => {
const hasVariant = this.compositeVariants.some((variant) => {
const variantNativeValue = variant?.options?.[nativeOptionIndex];
if (variantNativeValue !== nativeOption.value) return false;
// Respect any ordinary Shopify option selections.
const otherNativeOptionsMatch = otherNativeSelections.every((selectedValue, selectorIndex) => {
if (!selectedValue) return true;
return variant?.options?.[selectorIndex] === selectedValue;
});
if (!otherNativeOptionsMatch) return false;
// Respect generated selections from every composite option,
// including partially selected composites. This is what makes
// the sparse tree work across multiple Shopify options.
return Array.from(
this.querySelectorAll('.option-selector[data-composite-option="true"]')
).every((otherNativeSelector) => {
const otherIndex = Number(otherNativeSelector.dataset.index) - 1;
const otherContainer = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(otherNativeSelector.dataset.index)}"]`
);
if (!otherContainer) return true;
const otherSelects = Array.from(
otherContainer.querySelectorAll('.composite-option-select')
);
const selectionsForOtherComposite = otherNativeSelector === nativeSelector
? selectedOtherParts
: this.getCompositeSelection(otherNativeSelector);
const otherNativeValue = variant?.options?.[otherIndex];
if (otherNativeValue == null) return false;
const otherParts = this.getCompositeParts(
otherNativeValue,
otherNativeSelector.dataset.compositeDelimiter
);
return selectionsForOtherComposite.every((selectedValue, componentIndex) => {
if (!selectedValue) return true;
const componentName = otherSelects[componentIndex]?.getAttribute('aria-label') || '';
return this.compositePartMatches(
componentName,
selectedValue,
otherParts[componentIndex]
);
});
});
});
return hasVariant;
});
// Build two complete option sets:
// 1. valid values, based on the current selections
// 2. all values that exist in the product, regardless of the current selections
//
// Valid values are displayed first. Invalid values remain visible in
// the dropdown so the customer can see the complete option tree.
const allValues = new Map();
const validValues = new Map();
const partName = select.getAttribute('aria-label') || '';
const isYear = partName.trim().toLowerCase() === 'year';
const addPartValue = (map, part) => {
if (part == null || part === '') return;
if (isYear) {
const rangeMatch = String(part).trim().match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
for (let year = Math.max(start, end); year >= Math.min(start, end); year -= 1) {
map.set(String(year), String(year));
}
return;
}
}
map.set(part, part);
};
nativeOptions.forEach((nativeOption) => {
addPartValue(allValues, nativeOption.parts[partIndex]);
});
possibleNativeValues.forEach((nativeOption) => {
addPartValue(validValues, nativeOption.parts[partIndex]);
});
const previousValue = select.value;
const previousYearRange = this.isCompositeYearRangePlaceholder(previousValue)
? this.getCompositeYearRangeFromPlaceholder(previousValue)
: '';
select.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = this.dataset.placeholderText || 'Please select';
select.appendChild(placeholder);
const validSortedValues = Array.from(validValues.entries());
const invalidSortedValues = Array.from(allValues.entries())
.filter(([value]) => !validValues.has(value));
const sortValues = (a, b) => {
if (isYear) return Number(b[0]) - Number(a[0]);
return a[1].localeCompare(b[1], undefined, {
numeric: true,
sensitivity: 'base'
});
};
validSortedValues.sort(sortValues);
invalidSortedValues.sort(sortValues);
const appendOption = ([value, label], invalid) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
if (invalid) {
option.classList.add('composite-option-invalid');
option.dataset.compositeInvalid = 'true';
// Inline styling provides a fallback for browsers that do not
// apply stylesheet rules to native elements.
option.style.color = '#b85c5c';
option.style.backgroundColor = '#fdeaea';
}
select.appendChild(option);
};
validSortedValues.forEach((entry) => appendOption(entry, false));
invalidSortedValues.forEach((entry) => appendOption(entry, true));
// Keep an informational native-range selection visible after a
// rebuild. It is presentation-only and is never marked invalid.
if (isYear && previousYearRange) {
const rangeMatch = previousYearRange.match(/^(\d{4})\s*-\s*(\d{4})$/);
const rangeStillAvailable = rangeMatch && nativeOptions.some((nativeOption) => {
const part = nativeOption.parts[partIndex];
return String(part).trim() === previousYearRange;
});
if (rangeStillAvailable) {
const rangeOption = document.createElement('option');
rangeOption.value = `__native_year_range__:${previousYearRange}`;
rangeOption.textContent = `${previousYearRange} (selected)`;
select.insertBefore(rangeOption, select.options[1] || null);
select.value = rangeOption.value;
return;
}
}
if (previousValue && allValues.has(previousValue)) {
const restoredOption = Array.from(select.options).find(
(option) => option.value === previousValue
);
if (restoredOption && !restoredOption.dataset.compositeInvalid) {
select.value = previousValue;
} else {
select.value = '';
}
} else {
select.value = '';
}
});
// During a Shopify section replacement, the generated controls have not
// been restored from the saved state yet. Do not overwrite that saved
// state with the freshly-created (blank) controls.
if (!this.restoringCompositeState) {
this.compositeSelectionState[nativeSelector.dataset.index] = selects.map(
(select) => select.value || null
);
}
}
/**
* Automatically selects a generated component when filtering leaves
* exactly one real choice. This is intentionally iterative: selecting one
* forced value can reduce another component to a single choice.
*
* Placeholder and informational Year-range options do not count as real
* choices. Only blank generated controls are auto-selected, so an explicit
* customer choice is never overwritten.
*/
autoSelectForcedCompositeOptions() {
if (this.autoSelectingForcedCompositeOptions) return;
this.autoSelectingForcedCompositeOptions = true;
try {
let changed = true;
let safety = 0;
while (changed && safety++ < 50) {
changed = false;
const selectors = Array.from(
this.querySelectorAll('.composite-option-select')
);
for (const select of selectors) {
// Never overwrite an explicit customer selection.
if (select.value) continue;
const realOptions = Array.from(select.options).filter((option) => {
if (!option.value) return false;
if (this.isCompositeYearRangePlaceholder(option.value)) return false;
// Only a genuinely valid option can be auto-selected. Invalid
// (red) options remain visible, but must never count toward the
// number of choices available for the current selection.
if (option.dataset.compositeInvalid === 'true') return false;
return !option.disabled;
});
if (realOptions.length !== 1) continue;
const forcedValue = realOptions[0].value;
select.value = forcedValue;
// Route the automatic selection through the same handler as a
// customer selection so composite state, filtering, and the
// hidden Shopify selector remain synchronized.
select.dispatchEvent(new Event('change', { bubbles: true }));
changed = true;
// Let the resulting change event update all dependent controls
// before looking for the next forced value.
break;
}
}
} finally {
this.autoSelectingForcedCompositeOptions = false;
}
}
/**
* Resets every generated composite selector when the customer explicitly
* chooses an option that is incompatible with the current selections.
* The invalid option itself is not retained: the customer is returned to
* a clean state where every option is available again.
*/
resetAllCompositeGeneratedSelections() {
this.autoSelectingForcedCompositeOptions = true;
try {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (container) {
container.querySelectorAll('.composite-option-select').forEach((select) => {
select.value = '';
});
}
this.compositeSelectionState[nativeSelector.dataset.index] = [];
this.compositeInteractionState[nativeSelector.dataset.index] = false;
this.clearCompositeNativeSelection(nativeSelector);
});
this.compositeUserInteracted = false;
this.dataset.compositeUserInteracted = 'false';
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
this.updateCompositeSelectorsFor(nativeSelector);
});
} finally {
this.autoSelectingForcedCompositeOptions = false;
}
}
/**
* Handles a generated dropdown change. The hidden Shopify selector is
* deliberately left blank until every generated component is selected.
*/
handleCompositeChange(evt) {
const generatedSelect = evt.target;
const compositeIndex = generatedSelect.dataset.compositeIndex;
const nativeSelector = this.querySelector(
`.option-selector[data-composite-option="true"][data-index="${CSS.escape(compositeIndex)}"]`
);
if (!nativeSelector) return;
// An invalid/red option is intentionally visible, but choosing it means
// the customer wants to change direction. Reset all composite groups so
// every option becomes available again.
const selectedOption = generatedSelect.selectedOptions?.[0];
if (selectedOption?.dataset.compositeInvalid === 'true') {
// The customer is deliberately changing direction. Keep the option
// they just clicked, but clear every OTHER generated component. This
// makes the clicked red option the starting point for the new path
// instead of immediately erasing the customer's click.
const clickedValue = generatedSelect.value || '';
this.autoSelectingForcedCompositeOptions = true;
try {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((selector) => {
const index = selector.dataset.index;
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(index)}"]`
);
if (!container) return;
const isClickedGroup = index === compositeIndex;
const groupSelects = Array.from(container.querySelectorAll('.composite-option-select'));
if (isClickedGroup) {
// Keep the clicked component and clear the rest of its group.
groupSelects.forEach((select) => {
if (select !== generatedSelect) select.value = '';
});
this.compositeSelectionState[index] = groupSelects.map((select) => select.value || null);
this.compositeInteractionState[index] = true;
} else {
groupSelects.forEach((select) => {
select.value = '';
});
this.compositeSelectionState[index] = [];
this.compositeInteractionState[index] = false;
this.clearCompositeNativeSelection(selector);
}
});
this.compositeUserInteracted = true;
this.dataset.compositeUserInteracted = 'true';
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
// Rebuild using the clicked option as the only active constraint.
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((selector) => {
this.updateCompositeSelectorsFor(selector);
});
} finally {
this.autoSelectingForcedCompositeOptions = false;
}
return;
}
this.compositeUserInteracted = true;
this.compositeInteractionState[compositeIndex] = true;
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeUserInteracted = 'true';
const selections = this.getCompositeSelection(nativeSelector).map((value) =>
this.isCompositeYearRangePlaceholder(value) ? null : value
);
// If the customer changes any component while the year dropdown is
// showing Shopify's informational range, the range is no longer a
// concrete customer year selection. The individual year choices remain
// available for an explicit choice.
const yearSelect = Array.from(
this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(compositeIndex)}"]`
)?.querySelectorAll('.composite-option-select') || []
).find((select) => select.getAttribute('aria-label')?.trim().toLowerCase() === 'year');
if (yearSelect && this.isCompositeYearRangePlaceholder(yearSelect.value)) {
yearSelect.value = '';
}
this.compositeSelectionState[compositeIndex] = selections.slice();
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
// Rebuild every composite group so each dropdown reflects the complete
// current state of the product, not just the group that was changed.
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((selector) => {
this.updateCompositeSelectorsFor(selector);
});
// Complete any other generated selectors that have become forced to a
// single valid value as a result of this selection.
this.autoSelectForcedCompositeOptions();
if (selections.some((value) => !value)) {
this.clearCompositeNativeSelection(nativeSelector);
return;
}
const nativeOptions = Array.from(
nativeSelector.querySelectorAll('.js-option[data-value]:not([data-value=""])')
);
const matchingNativeOption = nativeOptions.find((option) => {
const parts = this.getCompositeParts(option.dataset.value, nativeSelector.dataset.compositeDelimiter);
const selects = Array.from(
this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(compositeIndex)}"]`
).querySelectorAll('.composite-option-select')
);
return selections.every((selectedValue, partIndex) => {
const partName = selects[partIndex].getAttribute('aria-label') || '';
return this.compositePartMatches(partName, selectedValue, parts[partIndex]);
});
});
if (!matchingNativeOption) {
this.clearCompositeNativeSelection(nativeSelector);
return;
}
const customSelect = nativeSelector.querySelector('custom-select');
if (!customSelect) return;
// Mark this specific composite group as intentionally selected.
this.compositeUserInteracted = true;
this.compositeInteractionState[compositeIndex] = true;
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeUserInteracted = 'true';
// If Shopify already has this exact native option selected (which is
// commonly true for the first variant on initial page load),
// selectOption() has nothing to change and therefore does not fire the
// variant-picker change handler. The customer has nevertheless just
// completed a valid selection, so explicitly fire the same change event
// that Shopify's custom-select normally produces.
if (matchingNativeOption.getAttribute('aria-selected') === 'true') {
customSelect.dispatchEvent(new CustomEvent('change', {
bubbles: true,
cancelable: false,
detail: {
variantId: matchingNativeOption.dataset.variantId || '',
productUrl: matchingNativeOption.dataset.productUrl || ''
}
}));
} else {
customSelect.selectOption(matchingNativeOption);
}
}
/**
* Clears the real Shopify selector without starting a variant request.
*/
clearCompositeNativeSelection(nativeSelector) {
const customSelect = nativeSelector.querySelector('custom-select');
if (!customSelect) return;
const placeholder = customSelect.querySelector(
'.custom-select__option[data-value=""]'
);
if (!placeholder || placeholder.getAttribute('aria-selected') === 'true') return;
this.suppressCompositeNativeChange = true;
customSelect.selectOption(placeholder);
}
/**
* Synchronizes generated controls from the current native Shopify value.
* This is used when a variant is loaded directly or after Shopify replaces
* variant-dependent content.
*/
syncCompositeSelectorsFromNative() {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (!container) return;
// Shopify may have already selected the first native variant for
// this group. That is not a customer choice. Only sync this group
// after the customer has interacted with this specific composite.
const compositeIndex = nativeSelector.dataset.index;
if (!this.compositeInteractionState[compositeIndex]) {
container.querySelectorAll('.composite-option-select').forEach((select) => {
select.value = '';
});
this.updateCompositeSelectorsFor(nativeSelector);
return;
}
const selectedNativeOption = nativeSelector.querySelector('.custom-select__option[aria-selected="true"]');
const nativeValue = selectedNativeOption?.dataset.value || '';
const selects = Array.from(container.querySelectorAll('.composite-option-select'));
if (!nativeValue) {
// A blank native selector means there is no Shopify variant to sync
// into the generated controls. Preserve customer selections only
// after they have actually interacted with the composite controls.
if (!this.compositeInteractionState[compositeIndex]) {
selects.forEach((select) => {
select.value = '';
});
}
this.updateCompositeSelectorsFor(nativeSelector);
return;
}
const parts = this.getCompositeParts(nativeValue, nativeSelector.dataset.compositeDelimiter);
selects.forEach((select, partIndex) => {
const partName = select.getAttribute('aria-label') || '';
const storedValue = this.compositeSelectionState[nativeSelector.dataset.index]?.[partIndex] || '';
const currentValue = select.value || '';
// The generated controls are the authoritative presentation state
// after the customer has interacted. Keep them when they still
// describe the selected native value. This is especially important
// for Year, where a generated year (e.g. 2006) maps to a range
// (e.g. 2000-2006) rather than to the literal native value.
if (storedValue) {
if (partName.trim().toLowerCase() === 'year') {
const rangeMatch = String(parts[partIndex] || '').match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const year = Number(storedValue);
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
if (year >= Math.min(start, end) && year <= Math.max(start, end)) {
select.value = storedValue;
return;
}
}
} else if (this.compositePartMatches(partName, storedValue, parts[partIndex])) {
select.value = storedValue;
return;
}
}
if (currentValue) {
if (partName.trim().toLowerCase() === 'year') {
const rangeMatch = String(parts[partIndex] || '').match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const year = Number(currentValue);
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
if (year >= Math.min(start, end) && year <= Math.max(start, end)) {
select.value = currentValue;
return;
}
}
} else if (this.compositePartMatches(partName, currentValue, parts[partIndex])) {
select.value = currentValue;
return;
}
}
if (partName.trim().toLowerCase() === 'year') {
const nativeYearRange = String(parts[partIndex] || '').trim();
if (/^\d{4}\s*-\s*\d{4}$/.test(nativeYearRange)) {
const rangeOptionValue = `__native_year_range__:${nativeYearRange}`;
select.value = rangeOptionValue;
// updateCompositeSelectorsFor() will create this informational
// option if it remains valid after filtering.
select.dataset.pendingNativeYearRange = nativeYearRange;
return;
}
}
select.value = parts[partIndex] || '';
});
this.updateCompositeSelectorsFor(nativeSelector);
// The rebuild above intentionally occurs before the informational
// range option is installed. Re-apply it now if the native Shopify
// value is a year range and no concrete customer year was retained.
selects.forEach((select, partIndex) => {
if (select.getAttribute('aria-label')?.trim().toLowerCase() !== 'year') return;
const pendingRange = select.dataset.pendingNativeYearRange;
if (!pendingRange) return;
delete select.dataset.pendingNativeYearRange;
const existing = Array.from(select.options).find((option) =>
option.value === `__native_year_range__:${pendingRange}`
);
if (existing) {
select.value = existing.value;
return;
}
const rangeOption = document.createElement('option');
rangeOption.value = `__native_year_range__:${pendingRange}`;
rangeOption.textContent = `${pendingRange} (selected)`;
select.insertBefore(rangeOption, select.options[1] || null);
select.value = rangeOption.value;
});
// The generated controls are now restored. Persist their actual values
// so the next Shopify section replacement receives the complete state
// for every composite group.
this.compositeSelectionState[nativeSelector.dataset.index] = selects.map(
(select) => select.value || null
);
});
this.restoringCompositeState = false;
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
}
removeListeners() {
this.removeEventListener('change', this.boundHandleVariantChange);
this.querySelectorAll('.opt-label, .custom-select__option').forEach((el) => {
el.removeEventListener('mouseenter', this.boundHandleLabelMouseEnter);
el.removeEventListener('touchstart', this.boundHandleLabelMouseEnter);
el.removeEventListener('mouseleave', VariantPicker.handleLabelMouseLeave);
});
}
/**
* Update parts of this component that require JS to display correctly.
*/
updateVariantContent() {
this.initCompositeSelectors();
this.syncCompositeSelectorsFromNative();
this.updateStatusClasses();
this.updateAddToCartButton();
this.updateSwatchLabel();
this.updateAvailability();
}
getProductForm() {
return this.section.querySelector('.js-product-form');
}
/**
* Handles 'mouseenter' events on option labels. Preloads links.
* @param {object} evt - Event object.
*/
handleLabelMouseEnter(evt) {
const label = evt.currentTarget;
if (label.dataset.preloaded) return; // Preload completed
if (label.dataset.preloadTimeout) return; // Preload waiting
if (label.matches('.opt-btn:checked + .opt-label, [aria-selected="true"]')) return; // Already selected
// Build option array if clicked
const input = label.htmlFor ? document.getElementById(label.htmlFor) : label;
const optionIds = this.getSelectedOptionIds();
const inputOptionIndex = Array.from(this.optionSelectors).indexOf(input.closest('.option-selector'));
optionIds[inputOptionIndex] = input.dataset.valueId;
// If all options are selected
if (optionIds.indexOf(null) === -1) {
// Wait to predict click-intent and preload link
label.dataset.preloadTimeout = setTimeout(() => {
label.dataset.preloaded = true;
label.removeAttribute('data-preload-timeout');
const combinedProductUrl = input.dataset.productUrl;
const url = this.constructVariantUrl(
combinedProductUrl,
optionIds,
input.dataset.variantId
);
theme.fetchCache.preload(url.toString());
}, 250);
}
}
/**
* Handles 'mouseleave' events on option labels. Cancels link preload if in/out fast.
* @param {object} evt - Event object.
*/
static handleLabelMouseLeave(evt) {
const label = evt.currentTarget;
clearTimeout(label.dataset.preloadTimeout);
label.removeAttribute('data-preload-timeout');
}
/**
* Create a URL for fetching a product or variant.
* @param {string} combinedProductUrl - URL for a combined product. Optional.
* @param {Array} optionIds - Selected option IDs to include in the URL. Optional.
* @param {string} variantId - Variant ID to use if not a combined product. Optional.
* @returns {URL} Variant URL
*/
constructVariantUrl(combinedProductUrl, optionIds, variantId) {
let url = null;
if (combinedProductUrl) {
url = new URL(combinedProductUrl, window.location.origin);
url.searchParams.set('option_values', optionIds.join(','));
} else {
url = new URL(this.dataset.url, window.location.origin);
}
const sectionIds = [this.dataset.sectionId];
if (!this.quickBuyContainer && !this.featuredProductContainer) {
document.querySelectorAll('.cc-variant-dependent-section [data-section-id]').forEach((e) => {
if (e.closest('.rte')) return;
sectionIds.push(e.dataset.sectionId);
});
}
url.searchParams.set('sections', sectionIds.join(','));
if (combinedProductUrl || !variantId || optionIds.includes(null)) {
url.searchParams.set('option_values', optionIds.join(','));
} else {
url.searchParams.set('variant', variantId);
}
return url;
}
/**
* Handles 'change' events on the variant picker element.
* @param {object} evt - Event object.
*/
handleVariantChange(evt) {
if (this.suppressCompositeNativeChange) {
this.suppressCompositeNativeChange = false;
return;
}
// Composite controls are presentation-only. They must not be treated as
// Shopify option selectors until all of their component values form an
// actual Shopify option value.
if (evt.target.matches('.composite-option-select')) {
this.handleCompositeChange(evt);
return;
}
const selectedOptionIds = this.getSelectedOptionIds();
// Immediately update swatch label
this.updateSwatchLabel();
// Construct new variant URL and fetch it, using theme cache
const combinedProductUrl = evt.target.dataset.productUrl || evt.detail?.productUrl;
const variantId = evt.target.dataset.variantId || evt.detail?.variantId;
const url = this.constructVariantUrl(combinedProductUrl, selectedOptionIds, variantId);
this.variantRequestId += 1;
const currentRequestId = this.variantRequestId;
theme.fetchCache.fetch(url.toString())
.then((responseText) => {
if (currentRequestId !== this.variantRequestId) return;
const responseData = JSON.parse(responseText);
// Update content
Object.entries(responseData).forEach(([section, htmlStr]) => {
const html = new DOMParser().parseFromString(htmlStr, 'text/html');
const sectionId = `shopify-section-${section}`;
let target = this.section;
let newContent = html.getElementById(sectionId);
if (this.section.id !== sectionId) {
if (!this.quickBuyContainer) {
target = document.getElementById(sectionId);
} else {
const quickBuyTemplate = html.getElementById('quick-buy-template')?.content.querySelector('.js-product');
if (quickBuyTemplate) newContent = quickBuyTemplate;
}
}
this.updateContent(target, newContent, combinedProductUrl);
});
// Restore focus
if (!evt.detail?.noFocus) {
document.querySelector(`#${evt.target.id}-button, input#${evt.target.id}`)?.focus();
}
// Update URL and announce changes
const newVariantPicker = this.section.querySelector('variant-picker');
setTimeout(() => newVariantPicker.announceChange(), 10);
});
}
/**
* Announces the current variant after a variant change.
*/
announceChange() {
const selectedOptionIds = this.getSelectedOptionIds();
if (!selectedOptionIds.includes(null)) {
this.updateUrl();
}
this.dispatchEvent(new CustomEvent('on:variant:change', {
bubbles: true,
detail: {
form: this.getProductForm(),
variant: this.variant,
selectedOptions: this.getSelectedOptions()
}
}));
}
/**
* Preserve composite UI state across Shopify's dynamic section refresh.
* Shopify replaces portions of the variant picker after a complete
* selection. The generated selectors are presentation state, so carry
* that state onto the replacement before it is initialized.
*/
preserveCompositeStateOnReplacement(sourcePicker, replacementPicker) {
if (!sourcePicker || !replacementPicker) return;
if (sourcePicker.compositeUserInteracted) {
replacementPicker.dataset.compositeUserInteracted = 'true';
}
if (sourcePicker.compositeInteractionState) {
replacementPicker.dataset.compositeInteractionState = JSON.stringify(
sourcePicker.compositeInteractionState
);
}
if (sourcePicker.compositeSelectionState) {
replacementPicker.dataset.compositeSelectionState = JSON.stringify(
sourcePicker.compositeSelectionState
);
}
}
/**
* Replace content when the variant or product changes.
* @param {Element} target - Target container for replacing content within.
* @param {document} newContent - New content containing elements to use.
* @param {boolean} productChange - Product is changing.
*/
updateContent(target, newContent, productChange) {
// If something's not right, do nothing
if (!target || !newContent) return;
// Preserve generated composite-picker state before Shopify replaces DOM.
// `target` is normally the Shopify section, not the variant-picker itself,
// so looking upward from target cannot reliably find the current picker.
// `this` is the picker that initiated this request and is therefore the
// authoritative source of the customer's composite selections.
const sourcePicker = this;
// Preselection - only update certain option value attributes
if (this.getSelectedOptions().indexOf(null) !== -1 && !productChange) {
target.querySelectorAll('.js-option[data-value-id]').forEach((input) => {
const newInput = newContent.querySelector(`.js-option[data-value-id="${input.dataset.valueId}"]`);
input.className = newInput.className;
if (newInput.dataset.variantId) {
input.dataset.variantId = newInput.dataset.variantId;
} else {
delete input.dataset.variantId;
}
if (input.classList.contains('custom-select__option')) {
input.innerHTML = newInput.innerHTML;
}
});
this.updateStatusClasses();
return;
}
let replaceSelector = '[data-dynamic-variant-content]';
if (productChange) {
replaceSelector += ', [data-dynamic-product-content]';
}
const toReplaceList = target.querySelectorAll(replaceSelector);
toReplaceList.forEach((toReplace) => {
const replaceWith = toReplace.dataset.dynamicVariantContent
? newContent.querySelector(`[data-dynamic-variant-content="${CSS.escape(toReplace.dataset.dynamicVariantContent)}"]`)
: newContent.querySelector(`[data-dynamic-product-content="${CSS.escape(toReplace.dataset.dynamicProductContent)}"]`);
if (!replaceWith) return;
// Dispatch 'on:variant:before-replace-element' event before replacing element
document.dispatchEvent(new CustomEvent('on:variant:before-replace-element', {
bubbles: true,
detail: {
toReplace,
replaceWith
}
}));
// If in quick buy, content needs mutating
if (this.quickBuyContainer && theme.quickBuy) {
theme.quickBuy.convertToQuickBuyContent(
replaceWith,
this.quickBuyContainer.dataset.productUrl
);
}
// Transfer basic input values
toReplace.querySelectorAll('input[id]:not([type="hidden"]), select[id]').forEach((sourceInput) => {
if (sourceInput.matches('.custom-select__native')) {
const customSelectId = sourceInput.id.replace('-native', '');
const targetInput = replaceWith.querySelector(`#${CSS.escape(customSelectId)}`);
const option = targetInput.querySelector(`.js-option[data-value="${CSS.escape(sourceInput.value)}"]`);
setTimeout(() => {
targetInput.selectOption(option);
}, 0);
} else {
const targetInput = replaceWith.querySelector(`#${CSS.escape(sourceInput.id)}`);
if (!targetInput) return;
if (sourceInput.type === 'radio' || sourceInput.type === 'checkbox') {
targetInput.toggleAttribute('checked', sourceInput.checked);
} else {
targetInput.value = sourceInput.value;
}
}
});
// Replace content
const isIdInput = replaceWith.matches('input[name="id"]');
if (isIdInput) {
// Update ID input - preserving listeners
[...replaceWith.attributes].forEach(
(attr) => toReplace.setAttribute(attr.name, attr.value)
);
toReplace.value = replaceWith.value;
// Defer change event until all forms are accurate
setTimeout(() => {
toReplace.dispatchEvent(new Event('change', { bubbles: true }));
}, 0);
} else {
// Remember toReplace becomes detached. Preserve composite UI state
// on the replacement because Shopify may create a fresh variant-picker
// instance after a complete variant is selected.
const replacementPicker = replaceWith.matches('variant-picker')
? replaceWith
: replaceWith.querySelector('variant-picker');
if (sourcePicker && replacementPicker) {
this.preserveCompositeStateOnReplacement(sourcePicker, replacementPicker);
}
toReplace.replaceWith(replaceWith);
}
// Do not mistake input value changes for variant changes - remove listeners
replaceWith.querySelectorAll('variant-picker').forEach((el) => el.removeListeners());
// Set values of any unselected elements again
// A fetched variant may not appear selected
const selectedOptionIds = this.getSelectedOptionIds();
selectedOptionIds.forEach((optionId) => {
const toSelect = replaceWith.querySelector(`.option-selector .custom-select__option[data-value-id="${optionId}"]:not([aria-selected="true"])`);
if (toSelect) {
setTimeout(() => {
toSelect.closest('custom-select').selectOption(toSelect);
}, 0);
}
});
// Update variant picker after potential changes are applied
setTimeout(() => {
replaceWith.querySelectorAll('variant-picker').forEach((el) => {
el.addListeners();
el.updateVariantContent();
});
}, 10);
});
}
/**
* Set status classes to help with styling.
*/
updateStatusClasses() {
const selectedOpts = this.getSelectedOptionIds();
const nullCount = selectedOpts.filter((x) => x === null).length;
const isSingleNullAtEnd = nullCount === 1 && selectedOpts[selectedOpts.length - 1] === null;
this.classList.toggle('variant-picker--preselection', nullCount > 0);
this.classList.toggle('variant-picker--pre-last-selection', nullCount > 0 && !isSingleNullAtEnd);
}
/**
* Copies the selected value into a swatch label. (Required for pre-selection.)
*/
updateSwatchLabel() {
this.querySelectorAll('.option-selector:has(.js-color-text)').forEach((option) => {
const label = option.querySelector('.js-color-text');
const input = option.querySelector('.js-option:is(:checked, [aria-selected="true"])');
label.textContent = input ? input.value : '';
});
}
/**
* Updates the availability status in option selectors.
*/
updateAvailability() {
if (this.dataset.availability === 'prune') {
const toChange = this.querySelectorAll('.js-option.is-unavailable:is(:checked, [aria-selected="true"])');
toChange.forEach((selected) => {
const toSelect = selected.closest('.option-selector').querySelector('.js-option:not(.is-unavailable, [data-value=""])');
if (toSelect) {
setTimeout(() => {
if (toSelect.closest('custom-select')) {
toSelect.closest('custom-select').selectOption(toSelect);
} else {
toSelect.click();
}
}, 20); // Trigger after any updateContent logic has occurred
}
});
}
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
this.updateCompositeSelectorsFor(nativeSelector);
});
// Filtering may have reduced one or more generated selectors to a
// single valid choice. Let the normal composite change handler process
// those forced selections.
this.autoSelectForcedCompositeOptions();
}
/**
* Updates the "Add to Cart" button label and disabled state.
*/
updateAddToCartButton() {
const productForm = this.getProductForm();
if (!productForm) return;
this.addBtn = productForm.querySelector('[name="add"]');
// Product not available
if (!this.productAvailable) {
this.addBtn.disabled = true;
this.addBtn.textContent = theme.strings.noStock;
return;
}
// Preselection
if (this.getSelectedOptions().indexOf(null) !== -1) {
if (this.addBtn.dataset.preselectionDisabled === 'true') {
this.addBtn.disabled = true;
this.addBtn.textContent = this.addBtn.dataset.preselectionText;
} else {
this.addBtn.disabled = false;
this.addBtn.textContent = this.addBtn.dataset.addToCartText;
}
return;
}
// No variant
if (!this.variant) {
this.addBtn.disabled = true;
this.addBtn.textContent = theme.strings.noVariant;
return;
}
// Sold out
if (!this.variant.available) {
this.addBtn.disabled = true;
this.addBtn.textContent = theme.strings.noStock;
return;
}
// Add to cart
this.addBtn.disabled = false;
this.addBtn.textContent = this.addBtn.dataset.addToCartText;
}
/**
* Updates the url with the selected variant id.
*/
updateUrl() {
if (this.dataset.updateUrl === 'false') return;
const url = this.variant ? `${this.dataset.url}?variant=${this.variant.id}` : this.dataset.url;
window.history.replaceState({ }, '', url);
}
/**
* Gets the variant data for a product.
* @returns {?object}
*/
getVariantData() {
const dataEl = this.section.querySelector('variant-picker [type="application/json"]');
return dataEl ? JSON.parse(dataEl.textContent) : null;
}
/**
* Get the selected values from a list of variant options.
* @returns {Array} Array of selected option values, value is null if not selected.
*/
getSelectedOptions() {
const selectedOptions = [];
this.optionSelectors.forEach((selector) => {
// A composite option is only a real Shopify selection after the
// customer has completed its generated controls. Shopify/theme code
// may otherwise preselect the first native variant during page load.
if (selector.dataset.compositeOption === 'true'
&& !this.compositeInteractionState[selector.dataset.index]) {
selectedOptions.push(null);
return;
}
if (selector.dataset.selectorType === 'dropdown') {
const selected = selector.querySelector('.custom-select__option[aria-selected="true"]');
selectedOptions.push(selected && selected.dataset.value !== '' ? selected.dataset.value : null);
} else {
const selected = selector.querySelector('input:checked');
selectedOptions.push(selected ? selected.value : null);
}
});
return selectedOptions;
}
/**
* Get the selected option value ids from a list of variant options.
* @returns {Array} Array of selected option value ids, value is null if not selected.
*/
getSelectedOptionIds() {
const selectedOptionIds = [];
this.optionSelectors.forEach((selector) => {
// Ignore Shopify's automatic native selection until the customer has
// actually interacted with a composite picker.
if (selector.dataset.compositeOption === 'true'
&& !this.compositeInteractionState[selector.dataset.index]) {
selectedOptionIds.push(null);
return;
}
if (selector.dataset.selectorType === 'dropdown') {
const selected = selector.querySelector('.custom-select__option[aria-selected="true"]');
selectedOptionIds.push(selected && selected.dataset.value !== '' ? selected.dataset.valueId : null);
} else {
const selected = selector.querySelector('input:checked');
selectedOptionIds.push(selected ? selected.dataset.valueId : null);
}
});
return selectedOptionIds;
}
/**
* Apply search parameters (set by product card swatches).
*/
applySearchParams() {
const searchParams = new URLSearchParams(window.location.search);
Array.from(searchParams.keys()).forEach((key) => {
const value = searchParams.get(key);
const optionSelectors = Array.from(this.optionSelectors);
const matchingOptionSelector = optionSelectors.find((x) => x.dataset.option === key);
if (!matchingOptionSelector) return;
if (matchingOptionSelector.dataset.selectorType === 'dropdown') {
const colorOptionDropdown = matchingOptionSelector.querySelector(`.custom-select__option[data-value="${CSS.escape(value)}"]:not([aria-selected="true"])`);
if (colorOptionDropdown) {
const customSelect = colorOptionDropdown.closest('custom-select');
const convertEvent = (evt) => {
evt.detail.noFocus = true;
customSelect.dispatchEvent(
new CustomEvent('change', { bubbles: true, cancelable: false, detail: evt.detail })
);
};
customSelect.addEventListener('change', convertEvent, { once: true });
customSelect.selectOption(colorOptionDropdown);
}
} else {
const matchingInput = matchingOptionSelector.querySelector(`input[value="${CSS.escape(value)}"]:not(:checked)`);
if (matchingInput) {
const matchingOptionValue = matchingOptionSelector.querySelector('.option-selector__label-value');
if (matchingOptionValue) {
matchingOptionSelector.querySelector('.option-selector__label-value').textContent = value;
}
matchingInput.checked = true;
matchingInput.dispatchEvent(
new CustomEvent('change', { bubbles: true, cancelable: false, detail: { noFocus: true } })
);
}
}
});
}
}
customElements.define('variant-picker', VariantPicker);
}
Snap offers lease-to-own financing.
Apply Now
Off The Grid Surplus | SKU:
OMWP122003BL34
Limited Life Time Warranty
We stand behind our products. Because of this, we now offer a limited lifetime warranty against manufacturing defects, to give you peace of mind. *Check Terms and Conditions.
The TB Pro Pant 2.0 is a great evolution of a quality staple in your travel wardrobe. Comfortable, durable, sharp-looking, and lots of utility define these go-anywhere, do-anything pants.
✔️ Water Resistant Coating ✔️ 4-Way Stretch Fabric ✔️ Quick Dry ✔️ Durable & Lightweight ✔️ Crotch Gusset ✔️ Knife Clip Patch ✔️ Upper Hip "Mag Pockets" (both sides) ✔️ Dual Zip-Closure Leg Pockets ✔️ Unique Partial Rear Seat Stitch = MORE STRETCH THAN BEFORE ✔️ Increased Belt Loops Height
94% Nylon | 6% Spandex
FIT :
Tapered Fit Pants are form-fitting and narrow from the thigh to the ankle leg opening.