If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeIf we click a color multiple times, nothing happens. I want this to unselect that color. To accomplish this, we don't need to do anything special. On click, we could look at the currentTarget
to see if it already has the selected
class.
If you think about it, we're sort of storing "state" information - which color is currently selected - inside HTML elements. Specifically, if we want to know what the currently-selected color is, we need to check to see which color square has the selected
class.
That's okay... but people! Stimulus gives us an object! And that means we can store info on it! We did this earlier with the "current count" on our counter controller.
So on click, let's start storing which color id is currently selected. At the top of the class, invent a new property: how about selectedColorId = null
.
... lines 1 - 2 | |
export default class extends Controller { | |
selectedColorId = null; | |
... lines 5 - 22 | |
} |
Then, down in selectColor()
, create a variable - const clickedColorId =
set to the event.currentTarget
code. Right below this, set the state property: this.selectedColorId = clickedColorId
.
... lines 1 - 11 | |
selectColor(event) { | |
const newColorId = event.currentTarget.dataset.colorId; | |
this.selectedColorId = newColorId; | |
... lines 15 - 21 | |
} | |
... lines 23 - 24 |
We don't really need a variable yet, but it will make life a bit easier in a minute. Down at the bottom, instead of referencing the event code, just use this.selectedColorId
.
... lines 1 - 11 | |
selectColor(event) { | |
... lines 13 - 20 | |
this.selectTarget.value = this.selectedColorId; | |
} | |
... lines 23 - 24 |
This by itself.... doesn't really do anything to help us. But we can now more easily use the property to figure out if the color that's being clicked is already selected.
Add an if statement near the top: if clickedColorId === this.selectedColorId
, then we know that we're clicking on a color box that is already selected.
For this situation, copy the classList
code from below, and make it event.currentTarget.classList.remove('selected')
. Also set this.selectedColorId = null
and this.selectTarget.value = ''
, or null
would be fine. Then return.
... lines 1 - 11 | |
selectColor(event) { | |
... lines 13 - 14 | |
if (newColorId === this.selectedColorId) { | |
event.currentTarget.classList.remove('selected'); | |
this.selectedColorId = null; | |
this.selectTarget.value = ''; | |
return; | |
} | |
... lines 23 - 31 | |
} | |
... lines 33 - 34 |
So when we click the already-selected color, we go here. Else we do the normal logic.
Let's try it! Refresh and let's inspect element, find the select
and temporarily take off the d-none
so we can see it.
Now, if we click red, it works! Click green, it works. Click green again... yes! It loses the border and the select
element changes.
Before we keep going, I want to reorganize things just a bit in our controller. End the selectColor
method early and move most of the logic into a new setSelectedColor()
method with a clickedColorId
argument.
Then, call this from above: this.setSelectedColor()
... and steal the event.currentTarget
code. We don't need a variable anymore.
... lines 1 - 2 | |
export default class extends Controller { | |
... lines 4 - 11 | |
selectColor(event) { | |
this.setSelectedColor(event.currentTarget.dataset.colorId) | |
} | |
setSelectedColor(newColorId) { | |
... lines 17 - 33 | |
} | |
... lines 35 - 41 | |
} |
This isn't going to quite work yet, but I want to explain why we're doing this. This is optional, but I like to have as many re-usable methods in my controller as possible. The nice thing about setSelectedColor()
is that it's not dependent on the event
: before we were reading event.currentTarget
.
Now, anyone can call this method from anywhere, pass a color id and... everything will just work! Well, it's going to work... once we finish refactoring.
We can't use event.currentTarget
anymore. But this is actually kind of cool! What we really need to find here is the currently-selected color box... since we're inside an if statement where we've determined that the user is tying to select a color that is already selected and we need to remove its selected
class.
Now, thanks to the selectedColorId
property, we can find the "currently selected color square" really easily! Let's add a helper method to do this: findSelectedColorSquare()
... lines 1 - 38 | |
findSelectedColorSquare() { | |
... line 40 | |
} | |
... lines 42 - 43 |
Inside return this.colorSquareTargets.find()
. What we're going to do is loop over all the color square targets and return the one whose data-color-id
attribute matches this.selectedColorId
.
Pass find()
a function with an element
argument. I'm going to use the super fancy single line syntax to return element.dataset.colorId === this.selectedColorId
.
... lines 1 - 38 | |
findSelectedColorSquare() { | |
return this.colorSquareTargets.find((element) => element.dataset.colorId === this.selectedColorId); | |
} | |
... lines 42 - 43 |
So this method will either return the Element if one is selected or null
. I'll add some docs above this to advertise that.
... lines 1 - 35 | |
/** | |
* @return {Element|null} | |
*/ | |
findSelectedColorSquare() { | |
... lines 40 - 43 |
Let's go use the new method: this.findSelectedColorSquare().classList.remove('selected')
. And... we have one more spot down here: where we add that class. Since we've already set the new selectedColorId
property, this will find the new element: this.findSelectedColorSquare().classList.add('selected')
.
... lines 1 - 15 | |
setSelectedColor(newColorId) { | |
if (newColorId === this.selectedColorId) { | |
this.findSelectedColorSquare().classList.remove('selected'); | |
... lines 19 - 23 | |
} | |
... lines 25 - 31 | |
this.findSelectedColorSquare().classList.add('selected'); | |
... line 33 | |
} | |
... lines 35 - 43 |
This shows off one of the nice things about storing state like selectedColorId
: we can create useful methods - like findSelectedColorSquare()
- and call them whenever we want.
Let's make sure I didn't break anything. Refresh, click red and click it again. All good!
Next: there's one big feature of Stimulus that we haven't talked about and it's actually brand new to Stimulus! It's the values API.
// composer.json
{
"require": {
"php": ">=8.1",
"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.18.5
"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.2.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.30.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
}
}
// package.json
{
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.12.13
"@popperjs/core": "^2.9.1", // 2.9.1
"@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
"@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
"@symfony/webpack-encore": "^1.0.0", // 1.0.4
"bootstrap": "^5.0.0-beta2", // 5.0.0-beta2
"core-js": "^3.0.0", // 3.8.3
"jquery": "^3.6.0", // 3.6.0
"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-autocomplete": "^2.0.1-phylor-6095f2a9", // 2.0.1-phylor-6095f2a9
"stimulus-use": "^0.24.0-1", // 0.24.0-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
}
}
I found at least in chrome that setting the value of selectTarget to null doesn't reset the selector to show its placeholder, but empty string '' as you had done does.