Denne artikkelen er bare tilgjengelig på engelsk

Multi-attribute variant selection in Craft Commerce

Variants in Craft Commerce are one-dimensional but your user experience doesn't need to be. This article shows you how to make a multi-dimensional "Add to cart" form.

First off, this isn't a plug'n'play tutorial where you can just copy-paste some code, commit, deploy, and step away. There are a lot of moving parts involved and you'll probably have to adapt some of the code to fit your project. The goal is to give you an understanding of the steps involved so that you are able to adapt the code to the case at hand.

The tutorial assumes a basic understanding of developing with Craft and Craft Commerce and a familiarity with the built-in features. If your variants don't need separate stock, price, or dimensions, you can probably use product line options instead, which is built-in and easy to use.

The tutorial is based on the work we've done on holzweiler.no and oslodeco.no, both of which are made with Craft Commerce and use the approach described.

An example of multi-dimensional variant selection on holzweiler.no.

The steps

Let's have a look at what steps we'll be going through:

  1. Setting up the product in Craft Commerce
  2. Outputting the variant dropdowns in the product template
  3. Using javascript to tie it together

I'll be using color and size as attributes in this example since these are commonly used in many web shops. However, what you name the attributes isn't important; they could be fabric and size, variant1 and variant2, or whatever your shop needs. 

This tutorial only shows how to deal with two different attributes. If you have more than two things become increasingly complex, but the same principles can still be applied.

I'll take into account the fact that variants may be out of stock and have different prices. When outputting variants as a one-dimensional dropdown that's not a big problem, you'd just remove or disable the variants that are out of stock. With multiple attributes this gets a bit more complicated as you have to update the available attributes based on the selection that is made.

With that in mind, let's move on to the first step.

1. Setting up the product in Craft Commerce

Let's jump straight into the variant field setup. You can use whatever field type you want to let the user set up the attributes. I prefer using categories as they give the shop-owner a nice interface with which to create, edit and sort the attributes. 

I've set up two category groups, one named Product Colors and one named Product Sizes. I limit the max levels to 1 and leave the categories without their own URLs. I've set up two fields, one named Variant Color with handle variantColor, and one named Variant Size with handle variantSize. Both are of field type Categories, have the corresponding category group we created as source, and have a limit of 1.

Next, add the two fields to your product type's variant fields layout.

This is what our variants field layout looks like. Feel free to add whichever fields your variants need in addition to these.

Now go ahead and create a test product and add various combinations of the two attributes to the variants. Add some variants that are out of stock and some that have different prices so that we can test that those factors also get taken into account.

This is what editing our product variant currently looks like.

2. Outputting the variant dropdowns in the product template

Let's dive into some code. Our goal here is to create an "Add to cart" form that works both for products that only have one variant (no dropdowns), several variants with only one attribute being used (one dropdown), and several variants that use both attributes (two dropdowns).

First off, I have a small project specific plugin where I've put one template variable that I'll be using. You could do the same thing in twig, but I prefer to have things like this in PHP as the code is much more readable.

If you don't already have such a plugin, jump over to the excellent pluginfactory.io and make one. You can call your plugin whatever you want (mine is called "Holzweiler", for future reference), and enable the light switch for "Variables". Download, unzip, put the plugin in your plugin folder and install it from the control panel.

Open up the plugin's variables file, HolzweilerVariables.php in my case, and add the following method to your class: 

public function getUniqueVariantAttributeValues($fieldName, $variants, $onlyInStock=false) {
    $addedIds = array();
    $sizes = array();

    foreach($variants as $variant) {
        $outOfStock = (($variant->stock <= 0) && ($variant->unlimitedStock == false));

        if (!$onlyInStock || ($onlyInStock && !$outOfStock)) {
            if (isset($variant->{$fieldName}[0])) {
                if (array_search($variant->{$fieldName}[0]->slug, $addedIds, true) === false) {
                    $sizes[] = $variant->{$fieldName}[0];
                    $addedIds[] = $variant->{$fieldName}[0]->slug;
                }
            }
        }
    }

    return $sizes;
}

This goes into your variables class. If you haven't made any plugins for Craft yet, fear not. Being able to create small, custom plugins for your projects really opens up a ton of opportunities. And template variables are by far the easiest and most useful type of plugin component, so it's a great place to start. 

This method takes a field name and some variants as (required) parameters and returns an array of unique attributes (in our case, categories) that are used in the variants for our product. This is what will be used to build the color and size dropdowns. If you used some other kind of field type for variants, you'll have to adapt this code for that.

Let's jump to our product template and add this: 

{% set variants = product.variants %}
{% set variantColors = craft.holzweiler.getUniqueVariantAttributeValues('variantColor', variants, false) %}
{% set variantSizes = craft.holzweiler.getUniqueVariantAttributeValues('variantSize', variants, false) %}

Make sure you replace "holzweiler" with the name of your plugin.

Before we get to the good stuff, add this somewhere in your template:

<div>
    Price: <span data-variant-price-full>{{ product.defaultVariant.price }}</span><br>
    Sale price: <span data-variant-price-sale>{{ product.defaultVariant.salePrice != product.defaultVariant.price ? product.defaultVariant.salePrice }}</span>
</div>

We're just outputting the price of the default variant and the sale price (if there is one). We'll target these spans later and replace the values with the price of the selected variant.

Next, we'll build the "Add to cart" form. If there is only one variant or only one attribute is being used (for instance, if a product has different sizes but not different colors), it's as simple as using the built-in functionality. The following code shows our starting point. You should test it to make sure that everything works so far:

{% if product.unlimitedStock or product.totalStock > 0 %}
	<form method="post">
		<input type="hidden" name="action" value="commerce/cart/updateCart">
		<input type="hidden" name="redirect" value="cart">
		{{ getCsrfInput() }}

		{% if variants | length > 1 %}
			<div>
				<label>Select Variant</label>
				<select name="purchasableId">
					{% for purchasable in variants %}
						<option {% if purchasable.stock <= 0 and purchasable.unlimitedStock == false %}disabled {% endif %}
								value="{{ purchasable.purchasableId }}">
							{{ purchasable.title }} {{ purchasable.variantColor[0].title ?? '' }} {{ purchasable.variantSize[0].title ?? '' }}
						</option>
					{% endfor %}
				</select>
			</div>
		{% else %}
			<input type="hidden" name="purchasableId" value="{{ variants[0].id }}">
		{% endif %}

		<div>
			<label>Quantity</label>
			<input type="text" pattern="[0-9]*" name="qty" value="1" maxlength="2"/>
		</div>
		
		<button type="submit">Add to bag</button>
	</form>
{% else %}
	<p>Sold out</p>
{% endif %}

Just your average "Add to cart" form.

If you have only one variant on a product there should be no dropdown. If you have multiple variants there should be a dropdown allowing you to select one and add it to your cart. Variants that are out of stock should be disabled.

Now let's expand (a lot) on this, and replace the code from the previous example with this:

{% if product.unlimitedStock or product.totalStock > 0 %}
    <form method="post">
        <input type="hidden" name="action" value="commerce/cart/updateCart">
        <input type="hidden" name="redirect" value="cart">
        {{ getCsrfInput() }}

        {# Base variant selector #}
        {% if variants | length > 1 %}
            <div>
                <label>Select Variant</label>
                <select name="purchasableId">
                    {% if ((variantColors | length > 0) or (variantSizes | length > 0)) %}
                        <option value="" data-color="" data-size=""
                                data-price="{{ product.defaultVariant.price }}"
                                data-sale-price="{{ product.defaultVariant.salePrice }}"></option>
                    {% endif %}

                    {% for purchasable in variants %}
                        <option {% if purchasable.stock <= 0 and purchasable.unlimitedStock == false %}disabled {% endif %}
                                value="{{ purchasable.purchasableId }}"
                                data-out-of-stock="{% if purchasable.stock <= 0 and purchasable.unlimitedStock == false %}true{% endif %}"
                                data-color="{{ purchasable.variantColor | length ? purchasable.variantColor[0].slug : '' }}"
                                data-size="{{ purchasable.variantSize | length ? purchasable.variantSize[0].slug : '' }}"
                                data-price="{{ purchasable.price }}"
                                data-sale-price="{{ purchasable.salePrice }}">
                            {{ purchasable.title }} {{ purchasable.variantColor[0].title ?? '' }} {{ purchasable.variantSize[0].title ?? '' }}
                        </option>
                    {% endfor %}
                </select>
            </div>

            {# Variant attributes – colors and sizes #}
            {% if ((variantColors | length > 0) or (variantSizes | length > 0)) %}
                {% if variantColors | length > 0 %}
                    <div>
                        <label>Color</label>
                        <select name="variantColor">
                            <option value="">Select color</option>
                            {% for variantColor in variantColors %}
                                <option value="{{ variantColor.slug }}">{{ variantColor.title }}</option>
                            {% endfor %}
                        </select>
                    </div>
                {% endif %}

                {% if variantSizes | length > 0 %}
                    <div>
                        <label class="">Size</label>
                        <select name="variantSize">
                            <option value="">Select size</option>
                            {% for variantSize in variantSizes %}
                                <option value="{{ variantSize.slug }}">{{ variantSize.title }}</option>
                            {% endfor %}
                        </select>
                    </div>
                {% endif %}
            {% endif %}

        {% else %}
            <input type="hidden" name="purchasableId" value="{{ variants[0].id }}">
        {% endif %}

        <div>
            <label>Quantity</label>
            <input type="text" pattern="[0-9]*" name="qty" value="1" maxlength="2"/>
        </div>

        <button type="submit">Add to bag</button>
    </form>
{% else %}
    <p>Sold out</p>
{% endif %}

Whooha, now we're talking!

If you refresh your product page you'll see that we now have three dropdowns: one with all the variants, one with all the colors that are used in the variants, and one with all the sizes that are used. Also, notice that we've added a bunch of data attributes to the variants dropdown.

You can select a variant from the variants dropdown and add it to your cart, but changing the values in the other two dropdowns doesn't do anything. Which brings us to the next step.

3. Using Javascript to tie it together

You've probably already got an idea of what we'll be doing with Javascript, but here's the gist of it: 

We'll add some listeners to the attribute dropdowns and use the values of the attributes to select the appropriate variant in the variants dropdown; we'll select the correct variant by using the information in the data attributes on the options. We'll have to make sure that we disable attributes that are either not available for the selected attribute, or are out of stock. We'll also update the price on the product page to reflect the selected variant.

Without further ado, here's some quick'n'dirty jQuery code (feel free to adapt this to whatever Javascript framework or module system you prefer!):

<script>
    $(document).ready(function() {
        // Let's get the three selects 
        var $colorSelect = $('select[name="variantColor"]'),
            $sizeSelect = $('select[name="variantSize"]'),
            $variantSelect = $('select[name="purchasableId"]');
        
        init();
        
        function init() {
            // If the color select exists, add an on change handler to it
            if ($colorSelect.length > 0) {
                $colorSelect.on('change', function(e) {
                    updateSelectStatus('color');
                });
            }
    
            // If the size select exists, add an on change handler to it
            if ($sizeSelect.length > 0) {
                $sizeSelect.on('change', function(e) {
                    updateSelectStatus('size');
                });
            }
    
            // Disable the colors and sizes that are completely sold out 
            disableSoldOutOptions();
        }
        
        /**
         * Triggered when one of the attribute dropdowns is selected. 
         */
        function updateSelectStatus(attribute) {
            if ($colorSelect.length > 0 && $sizeSelect.length > 0) {
                if (attribute === 'color') {
                    disableUnavailableOptions('color', $colorSelect.val());
                } else if (attribute === 'size') {
                    disableUnavailableOptions('size', $sizeSelect.val());
                }

                selectVariant($colorSelect.val(), $sizeSelect.val());
            } else {
                if (attribute === 'color') {
                    selectVariant($colorSelect.val(), '');
                } else {
                    selectVariant('', $sizeSelect.val());
                }
            }
        }

        /**
         * Selects a variant based on the value of the two attribute dropdowns
         */
        function selectVariant(color, size) {
            var $variantOptions = $variantSelect.find('option');
            var $selectedOption = null;

            $variantSelect.val('');

            $variantOptions.each(function(i) {
                if (String($(this).data('color')) === color && String($(this).data('size')) === size) {
                    $variantSelect.val($(this).attr('value'));
                    $selectedOption = $(this);
                }
            });

            updatePrice($selectedOption);
        }
        
        /**
         * Based on the attribute and value the user selected, disable all options
         * in the other dropdown that don't have a variant with this value.
         */
        function disableUnavailableOptions(attribute, val) {
            var $variantOptions = $variantSelect.find('option');
            var reverseAttribute = attribute === 'color' ? 'size' : 'color';

            if (attribute === 'color') {
                $sizeSelect.find('option').removeAttr('disabled');
            } else {
                $colorSelect.find('option').removeAttr('disabled');
            }

            if (val!=='') {
                var available = [];
    
                $variantOptions.each(function(i) {
                    if ($(this).data(attribute) === val && !$(this).is(':disabled')) {
                        available.push($(this).data(reverseAttribute));
                    }
                });
    
                var $otherSelect = attribute === 'color' ? $sizeSelect : $colorSelect;
    
                $otherSelect.find('option').each(function() {
                    var $opt = $(this);
                    if (($.inArray($opt.attr('value'), available) !== -1) || ($opt.attr('value') === '')) {
                        $opt.removeAttr('disabled');
                    } else {
                        $opt.attr('disabled', 'disabled');
                    }
                });
            }

            disableSoldOutOptions();
        }

        /**
         * Disables sold out options. Loops over the two selects and checks if
         * there is a variant in the variants dropdown that is not out of stock.
         */
        function disableSoldOutOptions() {
            var $variantOptions = $variantSelect.find('option');

            if ($colorSelect.length > 0) {
                $colorSelect.find('option').each(function() {
                    var $opt = $(this);
                    var found = false;

                    $variantOptions.each(function(i) {
                        if (($(this).data('out-of-stock') !== true) && (String($(this).data('color')) === $opt.val())) {
                            found = true;
                        }
                    });

                    if (!found) {
                        $opt.attr('disabled', 'disabled');
                    }
                });
            }

            if ($sizeSelect.length > 0) {
                $sizeSelect.find('option').each(function() {
                    var $opt = $(this);
                    var found = false;

                    $variantOptions.each(function(i) {
                        if (($(this).data('out-of-stock') !== true) && (String($(this).data('size')) === $opt.val())) {
                            found = true;
                        }
                    });

                    if (!found) {
                        $opt.attr('disabled', 'disabled');
                    }
                });
            }
        }
        
        /**
         * Updates the price based on the variant that was selected. You'll probably
         * want to do some currency formatting in here, and maybe remove the sale
         * price if it's the same as the full price.
         */
        function updatePrice($option) {
            if ($option !== null) {
                var $priceFull = $('[data-variant-price-full]');
                var $priceSale = $('[data-variant-price-sale]');

                if ($priceFull.length > 0) {
                    $priceFull.text($option.data('price'));
                }
                
                if ($priceSale.length > 0) {
                    $priceSale.text($option.data('sale-price'));
                }
            }
        }
    });
</script>

Make sure you also include jQuery somewhere in your template before this code.

If you refresh the browser, you should now be able to select attributes and see that the variant dropdown automatically selects the one that corresponds to your choices. If you select an attribute (let's say the color "Black"), that doesn't have variants for all the attributes in the other dropdown (let's say there is no black t-shirt in size "XXL"), the unavailable attributes should be disabled. Also, if attributes are sold out, they should be disabled.

That concludes the tutorial! 

There's still a lot to be desired from our "Add to cart" form. You probably want to hide the variants dropdown visually when there are color and/or size dropdowns. You can do this with some twig and css, or in your Javascript. Just make sure the variants dropdown is visible if the user doesn't have Javascript enabled (or an error occurred) and hide the attribute dropdowns in those cases. You probably also want to submit the form with AJAX, and make everything nice and pretty with some CSS. But I'll leave all of that in your capable hands. :-)

If you liked this article or have any questions, leave a comment below or let me know on Twitter or in the Craft Slack.

Posted in ⟶

  • Craft