I built something similar for a client recently (in Craft, but not Craft Commerce) that required two variations (size and colour) where not all variations were compatible (ie. some sizes were only available in some colours, and vice versa).
The approach I took was to mimic Shopify’s approach. Each product has a table with three columns (size, colour, product code). I didn’t include price because it is always the same regardless of variations, but you could add that easily.
On the page Craft outputs a single select input in case JavaScript fails for any reason. Something like:
{% if entry.options|length %}
<div class="product__options">
<form class="#" action="#" method="post">
<div class="form__wrapper">
<fieldset class="form__group">
<div class="select__wrapper">
<label class="label" for="opt__opt">Options</label>
<select class="input--select" id="opt__opt" name="options">
{% for row in entry.options %}
<option class="sel__option" data-colour="{{ row.colour }}" data-size="{{ row.size }}" data-code="{{ row.code }}" value="{{row.code}} - {{ row.size }} - {{ row.colour }}">Size: {{ row.size }} | Colour: {{ row.colour }}</option>
{% endfor %}
</select>
</div>
</fieldset>
<fieldset class="form__group form__group--submit">
<button class="button button--submit" type="submit" name="submit" id="opt__btn">Get Quick Quote</button>
</fieldset>
</div>
</form>
</div>
{% endif %}
Then, JavaScript lifts the data out of the select, and creates two new selects and a hidden input (the latter being for the product code). When the selects’ value changes the values in the other input are compared to make sure they are a valid combination (if not that option is disabled):
// holds the option data
let option_data = [];
// holds the inputs so can be manipulated
let size_input,
colour_input,
code_input;
// creates the wrapper for the select
function createSelectWrapper(parent)
{
let sw = document.createElement("div");
sw.setAttribute("class", "select__wrapper");
parent.appendChild(sw);
return sw;
}
// creates the label for the select
function createSelectLabel(parent, target, label)
{
// create wrapper
let lw = document.createElement("div");
lw.setAttribute("class", "label__wrapper");
// create label
let lbl = document.createElement("label");
lbl.setAttribute("class", "label");
lbl.setAttribute("for", target);
lbl.innerHTML = label;
lw.appendChild(lbl);
parent.appendChild(lw);
}
// creates a select element
function createSelect(parent, id, name, prompt, array)
{
// create select
let sel = document.createElement("select");
// set attributes
sel.setAttribute("id", id);
sel.setAttribute("name", name);
sel.setAttribute("class", "input--select");
// create default
let def = document.createElement("option");
def.innerHTML = prompt;
def.setAttribute("disabled", "disabled");
def.setAttribute("selected", "selected");
sel.appendChild(def);
let l = array.length;
for(let i = 0; i < l; i++)
{
// create option
let opt = document.createElement("option");
// set attributes
let val = array[i];
opt.innerHTML = val;
opt.setAttribute("value", val);
opt.setAttribute("class", "option");
// append option
sel.appendChild(opt);
}
// insert select
parent.appendChild(sel);
//return reference
return sel;
}
// creates a hidden field to hold product code
function createHidden(parent, id, name)
{
// create hidden input
let inp = document.createElement("input");
// set attributes
inp.setAttribute("type", "hidden");
inp.setAttribute("id", id);
inp.setAttribute("name", name);
// add to page
parent.appendChild(inp);
// return reference
return inp;
}
// checks if value is unique
function getValueUnique(arr, val)
{
let i = arr.length,
isFound = false;
while(i--)
{
if(arr[i] == val)
{
isFound = true;
break;
}
}
return !isFound;
}
// toggles the enabled/disabled of the submit button
function toggleSubmitBtn(willEnable)
{
let submitBtn = document.getElementById("opt__btn");
if(willEnable)
{
submitBtn.removeAttribute("disabled");
}
else
{
submitBtn.setAttribute("disabled", "disabled");
}
}
// inits the options fields in the form
function initOptions()
{
// grab the current input
let orig_options = document.getElementById("opt__opt");
let orig_options_parent = orig_options.parentNode;
let orig_options_grandparent = orig_options_parent.parentNode;
let orig_options_children = orig_options.getElementsByClassName("sel__option");
// create colour Array
let colour_arr = [];
// create size Array
let size_arr = [];
// get the current data
let l = orig_options_children.length;
for(let i=0; i < l; i++)
{
const t = orig_options_children[i].dataset;
var json = JSON.stringify({
colour: t.colour,
size: t.size,
code: t.code
})
option_data.push(json);
// check if it needs to add to arrays
if(getValueUnique(colour_arr, t.colour)) { colour_arr.push(t.colour); }
if(getValueUnique(size_arr, t.size)) { size_arr.push(t.size); }
}
//sort arrays (just in case)
colour_arr.sort();
size_arr.sort(function(a, b) {return a - b});
// remove the old dropdown
orig_options_grandparent.removeChild(orig_options_parent);
// create select wrapper
let size_wrapper = createSelectWrapper(orig_options_grandparent);
let colour_wrapper = createSelectWrapper(orig_options_grandparent);
// add labels
createSelectLabel(size_wrapper, "opt__size", "Size", true);
createSelectLabel(colour_wrapper, "opt__colour", "Colour");
// create new selects
size_input = createSelect(size_wrapper, "opt__size", "size", "Choose Size", size_arr);
colour_input = createSelect(colour_wrapper, "opt__colour", "colour", "Choose Colour", colour_arr);
// create hidden field to hold product code
code_input = createHidden(orig_options_grandparent, "opt__code", "product_code");
// listen for changes on the options
size_input.addEventListener("change", onOptionChange);
colour_input.addEventListener("change", onOptionChange);
}
// handles the disabling of an option
function disableOption(option, isSize)
{
option.setAttribute("disabled", "disabled");
option.innerHTML = option.getAttribute("value") + " - not available in your chosen " + (isSize ? "colour" : "size");
}
// handles the enabling of an option
function enableOption(option)
{
option.removeAttribute("disabled");
option.innerHTML = option.getAttribute("value");
}
// event triggered by one of the option dropdowns changing
function onOptionChange(event)
{
// identify selects
let triggerSelect = event.target,
targetSelect = triggerSelect == size_input ? colour_input : size_input;
// first set the targetSelect to all be not available
let targetOptions = targetSelect.getElementsByClassName("option");
let l = targetOptions.length;
for(let i = 0; i < l; i++)
{
disableOption(targetOptions[i], targetSelect == size_input);
}
// next loop through the data to find the ones that should be turned back on
let triggerProp = triggerSelect == size_input ? "size" : "colour";
let targetProp = triggerProp == "size" ? "colour" : "size";
let triggerVal = event.target.value;
let availVals = [];
l = option_data.length;
for(i=0; i < l; i++)
{
let jsonData = JSON.parse(option_data[i]);
if(triggerVal == jsonData[triggerProp])
{
availVals.push(jsonData[targetProp]);
}
}
// turn the target select options back on
l = availVals.length;
for(i = 0; i < l; i++)
{
let m = targetOptions.length;
for(let j = 0; j < m; j++)
{
if(targetOptions[j].getAttribute("value") == availVals[i])
{
// found a match
enableOption(targetOptions[j]);
}
}
}
// see if there’s a product code
// (triggerVal is already set)
targetVal = targetSelect.value;
l = option_data.length;
for(i = 0; i < l; i++)
{
let jsonData = JSON.parse(option_data[i]);
if(jsonData[triggerProp] == triggerVal && jsonData[targetProp] == targetVal)
{
code_input.value = jsonData.code; // set product code in hidden input
toggleSubmitBtn(true); // enable submit button
break;
}
}
}
// inits page
function init()
{
// disable button
toggleSubmitBtn(false);
// init options dropdown
initOptions();
}
// check document is ready
document.addEventListener('DOMContentLoaded', function(){
init();
})
(Some of that might look verbose, I’ve copied and pasted from a large file and tried to strip out anything irrelevant.)
It’s not live yet, but it’s testing fine.
I think for multi-variant products it’s the simplest approach for the client, because they can update the product in a simple table and it’s reasonably idiot-proof.