Code Kata: Building A Tri-State Switch In Alpine.js
At work, I've been building a bulk-export feature for user prototypes. In the export experience, you can enable and disable "hotspot hinting". But, it's not exactly a binary experience. Meaning, I wanted to include a partially on state in which the hotspot hints were initial invisible; but, which would flash briefly when the user clicked on the prototype screen and failed to make contact with a transparent hotspot. To facilitate this configuration, I created a tri-state switch / toggle component:
This export is built in legacy Angular.js. As a code kota, I wanted to see if I could build the same kind of tri-state switch using Alpine.js. The primary challenge with Alpine.js, in this context, is that it doesn't really have a sense of "component inputs". But, we can use the x-effect
directive to propagate changes in the parent scope down into the state of our switch component.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
The way in which I architected my tri-state toggle requires both a set of states and a selected state to be passed in. Internally, the tri-state switch component then maps those inputs onto a "phase" of the switch: on
, off
, and partial
. This mapping happens inside a reconcile()
method which is invoked via x-effect
:
<div
x-data="TriSwitch()"
x-effect="reconcile({
states: options,
state: selected
})">
</div>
In this case, the options
and selected
values are being provided by the parent scope. And, anytime they change, Alpine.js will detect the change and reactively invoke the reconcile()
method on the TriState()
component instance, passing-in the latest object as defined within the x-effect
expression.
The TriState()
component then calculates the index of the given state
reference within the given states
array and recalculates the internal "phase" of the tri-state toggle. The code for the reconcile()
method is very small:
// Inputs are being passed-in via ex-effect.
function reconcile( inputs ) {
// this.phases == [ "off", "partial", "on" ]
this.phase = (
this.phases[ inputs.states.indexOf( inputs.state ) ] ||
this.phases[ 0 ]
);
}
By using x-effect
to bind the parent scope to the (tri-state switch) child scope, the child component doesn't have to know anything about the parent component. To see this in action, I've created a demo in which the parent component has three possible modes: None
, Some
, and All
, which will be mapped onto the off
, partial
, and on
of the tri-state toggle using the reconcile()
method above:
Here's the full HTML and Alpine.js code for this demo:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body>
<h1>
Creating A Tri-State Switch In Alpine.js
</h1>
<div x-data="Demo">
<p>
<button
@click="setMode( 'None' )"
:class="{ active: ( selected === 'None' ) }">
None
</button>
<button
@click="setMode( 'Some' )"
:class="{ active: ( selected === 'Some' ) }">
Some
</button>
<button
@click="setMode( 'All' )"
:class="{ active: ( selected === 'All' ) }">
All
</button>
</p>
<!--
The x-effect directive works by re-evaluating the overall expression every
time one of the embedded dependencies changes. As such, any time either the
"options" or the "selected" parent state is updated, the tri-switch
component's reconcile() method will be invoked and the updated state will be
passed-in, allowing the internal tri-switch state to be mapped from the parent
scope state (ie, one-way data binding).
-->
<div
x-data="TriSwitch()"
x-effect="reconcile({
states: options,
state: selected
})"
@click="cycleMode()"
class="tri-switch"
:class="{
on: ( phase === 'on' ),
off: ( phase === 'off' ),
partial: ( phase === 'partial' )
}">
<div class="tri-switch__track">
<div class="tri-switch__thumb"></div>
</div>
</div>
</div>
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
<script type="text/javascript">
function Demo() {
return {
options: [ "None", "Some", "All" ],
selected: "Some",
// Public methods.
cycleMode,
setMode,
};
// ---
// PUBLIC METHODS.
// ---
/**
* I cycle to the next selectable mode.
*/
function cycleMode() {
var selectedIndex = this.options.indexOf( this.selected );
// If the NEXT index is undefined, circle back to the front of the modes.
if ( this.options[ ++selectedIndex ] === undefined ) {
selectedIndex = 0;
}
this.selected = this.options[ selectedIndex ];
}
/**
* I set the selected mode.
*/
function setMode( newMode ) {
this.selected = newMode;
}
}
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
function TriSwitch() {
return {
phases: [ "off", "partial", "on" ],
phase: "off",
// Public methods.
reconcile,
};
// ---
// PUBLIC METHODS.
// ---
/**
* I reconcile the parent scope state with the local input bindings.
*/
function reconcile( inputs ) {
this.phase = (
this.phases[ inputs.states.indexOf( inputs.state ) ] ||
this.phases[ 0 ]
);
}
}
</script>
</body>
</html>
This was fun to figure out, but it definitely feels like it cuts against the grain of what Alpine.js is really good at (which is binding to event handlers and then updating dynamic attributes). Every time I try to wrap more encapsulated logic into a reusable component, it leaves me wanting for something more substantial like Angular.
Want to use code from this post? Check out the license.
Reader Comments
How unbelievably difficult it is to do what is essentially a variable that fits in two bits with still 25% memory wasted should tell us something about how awful the framework is we work in.
You have a setter, a getter, a cycle function, a conditional, an array overflow error handler, a class, an instance of said class, and a ton of boiler plate on top. That's astonishingly complicated for what you get.
This is all the pseudocode you need:
And you turned it into a monster.
@Samuel,
I'll admit there's a lot of code here; but, it's not as obvious to me (as you make it out to be). I had to make some decisions that I'm not completely confident in.
For example, with a simple Boolean switch, the fact that there are only 2 states makes things much simpler. Cycling direction matter; and, there's no need to pass-in any additional data since True/False are the only possible options.
When it comes 3+ states, I tried to build-in more customization. For example, you mention the need (or lack of need) of a "cycle" method. But, if the tri-state switch is in state "2 of 3", should cycling move forward to state 3? Or backward to state 1? I don't think there's a correct answer. So, I chose to defer that to the calling context.
Then, I could have passed-in the state index as a property (ex,
state="1"
(or2
or3
); but then I felt like that wasn't a great developer experience since now the calling context had to handle theindexOf()
stuff. So, I chose to instead pass in thestates
andstate
, and then have the toggle component try to work out the index.There's also stuff here that is somewhat "leaky" because Alpine.js doesn't have great encapsulation since it's designed to work on top of server-rendered HTML. If this were something like Angular, which has strong encapsulation, at the very least the HTML markup would have been much simpler.
All to say, there were almost certainly different choices that could have been made. But, I think they all likely have pros-and-cons.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →