Baja Designs S2 Sport Black LED Auxiliary Light Pod Pair - Universal
Regular price
$243.95 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
Baja Designs | SKU:
547811
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.
Description
The S2 Sport features the same quality, fit, and finish that is synonymous with the Baja Designs name, at an attractive price point. The S2 Sport is ideal as a fog, cornering, rack, or reverse light and much more. A compact and powerful light, featuring quality that is second to none.
Satisfaction Guarantee – 30-Day Money Back Guarantee
Limited Lifetime Warranty – Complete Purchase Protection
uService – Replaceable Lenses And Optics
ClearView – All The Light, Right Where You Need It
MoistureBlock – Waterproof, Rain Proof, Submersible
CopperDrive – Only LED Driven At 100 Percent
5000K Daylight – Less Driver Fatigue, Natural Color
SKU: 547801
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Spot
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Spot; Clear; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Clear
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547803
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Driving/Combo
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Driving/Combo; Clear; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Clear
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547805
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Wide Cornering
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Wide Cornering; Clear; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Clear
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547806
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Work/Scene
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Work/Scene; Clear; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Clear
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547811
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Spot
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Spot; Amber; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Amber
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547813
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Driving/Combo
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Driving/Combo; Amber; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Amber
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547815
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Wide Cornering
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Wide Cornering; Amber; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Amber
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
SKU: 547816
Dimensions
Depth (in.)
1.680
Height (in.)
1.760
Length (in.)
2.930
Weight (lbs.)
0.500
Specifications
Amperage Rating (A)
0.900
Average Rated Life (hr.)
49,930
Beam Pattern
Work/Scene
Bezel Material
Billet Machined Aluminum
Brightness (Lumens)
1581
Description
Work/Scene; Amber; Pair
Housing Color
Black
Housing Material
Powder Coated Cast Aluminum
Lens Color
Amber
Lens Material
Hardcoated Polycarbonate
Light Quantity
2
Lighting Modes
Beam
Lighting Quantity
2
Lighting Technology
LED
Lighting Type
Forward Projecting
Mounting Hardware Included
Yes
Mounting Hardware Material
Stainless Steel
Overvoltage Protection
Built-In
Product Type
S2 Sport LED Light Pod Pair w/ Wiring Harness
Wattage (W)
12.420
Wiring Harness Included
Yes
Standards & Compliance
Standards/Compliance (Durability)
Exceeds MIL-STD810G (Mil-Spec Testing)
Standards/Compliance (Impact Resistance)
IK10 Compliant (Mechanical Impact Testing)
Standards/Compliance (Water Resistance)
IP69K (Waterproof up to 9ft & Pressure Washable)
Installation instructions