Bootstrapping a "Color Selector" Form Element

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Let's work on an even more interesting and complex example. Some products come in multiple colors. To select a color and quantity, I built a nice, boring Symfony form. If you're curious, you can see this form in src/Form/AddItemToCartForm.php. The color is an EntityType to a Color entity, which means it renders as a dropdown.

But... the design team isn't happy, and I don't blame them! They want something more interesting where users can actually see what the color looks when they they choose it.

So here's the goal: replace this select element with small "color square" boxes where we choose the color by clicking on the box. The strategy we're using here is called progressive enhancement. That's a philosophy where we get the page working first - with a nice boring, normal form - then make it better - "enhance it" - with JavaScript when we need to.

Open up the template for this page: it's down here at templates/product/_cart_add_controls.html.twig. If you're wondering, I extracted the form rendering code into this template "partial" because it's also used on the checkout sidebar.

Yup, this is a normal Symfony form that uses the normal Symfony form rendering functions.

Building the Color Boxes

Ok: to build the color boxes, should we add that HTML in Twig or in a Stimulus controller? The answer is almost always: in Twig. Why? Because Stimulus is all about rendering HTML on the server and adding behavior in JavaScript.

Since not all products come in multiple colors, this if statement checks to see if our form even has a color field. For forms that do have this field, each product has a different list of possible colors.

If you want to follow the entity structure, our form is bound to a CartItem object. We can go from CartItem to Product... and then once we have the Product, it has a colors property that holds a collection of Color objects that this product is available in. This is what we need to loop over to create the color boxes.

To do this, we'll use a little form trick: {% for color in addToCartForm.vars.data - that will give us the CartItem object - then .product.colors. And an endfor, then inside, we can say {{ color.hexColor }}: that's one of the properties on the Color object.

... lines 1 - 3
{% if addToCartForm.color is defined %}
... lines 5 - 6
{% for color in addToCartForm.vars.data.product.colors %}
<div>#{{ color.hexColor }}</div>
{% endfor %}
{% endif %}
... lines 11 - 28

Let's try that. Move over and... ah! It works, but it's ugly.

To turn this into some schwweet color boxes, change the div to a span, clear out its contents entirely and give it a class="color-square": that's a CSS class I already created to make this a small square with rounded corners. The only special thing we need to do is set the background color.

Do that with style="background-color: " then I'll use the rgb() syntax, passing color.red, color.green and color.blue - three more properties, or more accurately, getter methods in my Color class.

... lines 1 - 6
{% for color in addToCartForm.vars.data.product.colors %}
<span
class="color-square"
style="background-color: rgb({{ color.red }}, {{ color.green }}, {{ color.blue }});"
></span>
{% endfor %}
... lines 13 - 31

Awesome! Check it out now. They're so cute!

Adding the Stimulus Controller

Now we need to make these little suckers functional. And that means we need a Stimulus controller.

Up in the assets/controllers/ directory, add a new file. Let's call it, how about, color-square_controller.js.

Notice the naming convention: the only thing that really matters is that we end in _controller.js. For the rest of the name, it could be color-square or color_square - it doesn't really matter, as long as it's all lowercase because the name is used in HTML attributes. Because I'm using dashes, the controller's name will be color-square.

Inside the file, we always start the same way: import { Controller } from 'stimulus' and export default class extends Controller.

I usually like to add a connect() method to make sure I've got everything hooked up correctly. Let's console.log(this.element.innerHTML)..

import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
console.log(this.element.innerHTML);
}
}

Now, go activate this in the template. But let's think: we need the controller to go around the three color boxes. But it also needs to go around the select element itself so that we can set its value when the user clicks a color box. The select element is rendered by form_widget().

So let's add a new <div data-controller="color-square">, put that around everything and indent.

... lines 1 - 3
{% if addToCartForm.color is defined %}
<div data-controller="color-square">
... lines 6 - 13
</div>
{% endif %}
... lines 16 - 33

Sweet! Let's take this puppy for a walk. Move over, refresh, and open up the console. Yes! Our controller is connected!

Adding the Action

Of course the goal of our Stimulus controller is not just to log something: it's to do something when we click each color square. So what we need is an action. In the template, on the color square span, add data-action="". Remember, the syntax for an action is the name of the controller - color-square - a #, then name of the method that should be called on our controller when this action happens. How about selectColor.

... lines 1 - 7
{% for color in addToCartForm.vars.data.product.colors %}
<span
... line 10
data-action="color-square#selectColor"
... line 12
></span>
{% endfor %}
... lines 15 - 34

Now, over in the controller, replace the connect() method with selectColor() and, just like normal JavaScript, this will be passed an event object. Let's console.log() that event to see what it looks like.

... lines 1 - 2
export default class extends Controller {
selectColor(event) {
console.log(event);
}
}

Move over, refresh and... click! Uh... nothing happens? The action is not working!

This is one of the trickier things about Stimulus: often, if you have a slight mistake - like you misspell your controller name - you won't get an error... it just won't work. There are reasons for why.... but it can be tricky. Watch your spelling closely.

But in this case, the mistake is something else. I added the action to a span element. Things like a tags, buttons, forms and form elements all have a default action like click or submit. A span... does not... which makes sense: a span doesn't do anything in normal HTML.

This means that if we want to add an action to a span, we need to specify it. Do this by adding click-> in front of the rest of the action syntax.

... lines 1 - 7
{% for color in addToCartForm.vars.data.product.colors %}
<span
... line 10
data-action="click->color-square#selectColor"
... line 12
></span>
{% endfor %}
... lines 15 - 34

Now when I click... it works! And we can see that we're passed a normal event object.

Stay Semantic

But... we're making our life harder than it needs to be! Why not just make these squares buttons instead? That'll simplify the action syntax and... it's just more correct: these really are buttons that the user will click.

Change the span to a button. And then add type="button".

... lines 1 - 7
{% for color in addToCartForm.vars.data.product.colors %}
<button
... line 10
type="button"
... lines 12 - 13
></button>
{% endfor %}
... lines 16 - 35

That will make sure that the button doesn't cause the form around it to submit when we click. And then, we do not need the click anymore: that is the default action for a button.

By the way, since our button doesn't have any text in it, to make this more accessible, we should add an aria-label attribute for screen readers, like aria-label="choose the color red".

Anyways, let's try this! Refresh, click and... woohoo!

Now that things are set up, let's actually... yea know, do something on click. First, we need to add a border to whichever square the user clicks so that it looks "selected". And second, we need to set the value on this select element so that the selected color is submitted with the form.

Let's work on the first part next and learn about the "current target" property on events.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.11.1
        "doctrine/doctrine-bundle": "^2.2", // 2.2.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.8", // 2.8.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^5.6", // v5.6.1
        "symfony/asset": "5.2.*", // v5.2.3
        "symfony/console": "5.2.*", // v5.2.3
        "symfony/dotenv": "5.2.*", // v5.2.3
        "symfony/flex": "^1.3.1", // v1.12.1
        "symfony/form": "5.2.*", // v5.2.3
        "symfony/framework-bundle": "5.2.*", // v5.2.3
        "symfony/property-access": "5.2.*", // v5.2.3
        "symfony/property-info": "5.2.*", // v5.2.3
        "symfony/proxy-manager-bridge": "5.2.*", // v5.2.3
        "symfony/security-bundle": "5.2.*", // v5.2.3
        "symfony/serializer": "5.2.*", // v5.2.3
        "symfony/twig-bundle": "5.2.*", // v5.2.3
        "symfony/ux-chartjs": "^1.1", // v1.1.0
        "symfony/validator": "5.2.*", // v5.2.3
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.3
        "twig/extra-bundle": "^2.12|^3.0", // v3.2.1
        "twig/intl-extra": "^3.2", // v3.2.1
        "twig/twig": "^2.12|^3.0" // v3.2.1
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.2.3
        "symfony/maker-bundle": "^1.27", // v1.28.0
        "symfony/monolog-bundle": "^3.0", // v3.6.0
        "symfony/stopwatch": "^5.2", // v5.2.3
        "symfony/var-dumper": "^5.2", // v5.2.3
        "symfony/web-profiler-bundle": "^5.2" // v5.2.3
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.12.13
        "@symfony/stimulus-bridge": "^2.0.0", // 2.0.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.0.4
        "bootstrap": "^4.6.0", // 4.6.0
        "core-js": "^3.0.0", // 3.8.3
        "react": "^17.0.1", // 17.0.1
        "react-dom": "^17.0.1", // 17.0.1
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-use": "^0.22.1", // 0.22.1
        "sweetalert2": "^10.13.0", // 10.14.0
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.0
        "webpack-notifier": "^1.6.0" // 1.13.0
    }
}