Managing State in CSS with Reusable JavaScript Functions – Part 2
In my previous article, which shall now retroactively be known as Managing State in CSS with Reusable JavaScript Functions – Part 1, we created a powerful reusable function which allows us to quickly add, remove and toggle stateful classes via click.
One of the reasons I wanted to share this approach was to see what kind of response it would generate. Since then I’ve received some interesting feedback from other developers, with some raising valid shortcomings about this approach that would have never otherwise occurred to me.
In this article, I’ll be providing some solutions to these shortcomings, as well as baking in more features and general improvements to make our reusable function even more powerful.
Article Series:
- Original article
- Managing State in CSS with Reusable JavaScript Functions (You are here!)
For reference, here’s the JavaScript from Part 1 for our reusable function as it stands:
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab classes list and convert to array
var dataClass = this.getAttribute('data-class');
dataClass = dataClass.split(", ");
// Grab linked elements list and convert to array
var dataClassElement = this.getAttribute('data-class-element');
dataClassElement = dataClassElement.split(", ");
// Grab data-class-behaviour list if present and convert to array
if(this.getAttribute("data-class-behaviour")) {
var dataClassBehaviour = this.getAttribute("data-class-behaviour");
dataClassBehaviour = dataClassBehaviour.split(", ");
}
// Grab data-scope list if present and convert to array
if(this.getAttribute("data-class-scope")) {
var dataClassScope = this.getAttribute("data-class-scope");
dataClassScope = dataClassScope.split(", ");
}
// Loop through all our dataClassElement items
for(var b = 0; b < dataClassElement.length; b++) {
// Grab elem references, apply scope if found
if(dataClassScope && dataClassScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(this, dataClassScope[b]),
// Grab all matching child elements of parent
elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-class-element and fits within scope
if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataClassElement[b]);
}
// Grab class we will add
var elemClass = dataClass[b];
// Grab behaviour if any exists
if(dataClassBehaviour) {
var elemBehaviour = dataClassBehaviour[b];
}
// Do
for(var c = 0; c < elemRef.length; c++) {
if(elemBehaviour === "add") {
if(!elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.add(elemClass);
}
}
else if(elemBehaviour === "remove") {
if(elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.remove(elemClass);
}
}
else {
elemRef[c].classList.toggle(elemClass);
}
}
}
});
}
Going forward, this is going to serve as the base for our improvements.
Let’s get started!
Accessibility
The most common piece of feedback I’ve received from other developers in response to Part 1 was the approach’s lack of consideration for accessibility. More specifically, it’s lack of support for ARIA attributes (or ARIA states if you prefer) and its failure to provide keyboard events for triggering our reusable function.
Let’s see how we can integrate both.
ARIA attributes
ARIA attributes form part of the WAI-ARIA specification. In the words of the specification they…
…are used to support platform accessibility APIs on various operating system platforms. Assistive technologies may access this information through an exposed user agent DOM or through a mapping to the platform accessibility API. When combined with roles, the user agent can supply the assistive technologies with user interface information to convey to the user at any time. Changes in states or properties will result in a notification to assistive technologies, which could alert the user that a change has occurred.
Revisiting the accordion example from part 1, an aria-expanded
attribute set to true
when the component is expanded, and vice versa when at its default state, would allow assistive technologies such as screen readers to better assess the component.
In addition to providing these benefits, as Ben Frain explores in his article, we can drop stateful classes and instead rely on ARIA attributes as our CSS hooks for styling some component state:
Adopting this approach results in what is (cringingly) referred to as a “Win Win” situation. We get to improve the accessibility of our web application, while also gaining a clearly defined, well-considered lexicon for communicating the states we need in our application logic.
For example, instead of:
.c-accordion.is-active .c-accordion__content {
[...]
}
We would have:
.c-accordion[aria-expanded="true"] .c-accordion__content {
[...]
}
Coming back to our reusable function, we’ll build in support so the data-class
attribute can also accept an ARIA attribute reference. Since we’re now manipulating attributes rather than just classes, it would make sense semantically to rename data-class
and all of its associated attributes to data-state
:
<div class="c-mycomponent" data-state="aria-expanded" data-state-element="c-mycomponent" aria-expanded="false" tabindex="0">
In the above example, clicking c-mycomponent
should toggle aria-expanded
on itself. Whilst in the below example, in addition to the previous behaviour my-class
would be removed from c-myothercomponent
.
<div class="c-mycomponent" data-state="aria-expanded, my-class" data-state-element="c-mycomponent, c-myothercomponent" data-state-behaviour="toggle, remove" aria-expanded="false" tabindex="0">
In addition to aria-expanded
, other examples of how ARIA attributes could be used instead of stateful classes are:
aria-disabled="true"
instead ofis-disabled
aria-checked="true"
instead ofis-checked
aria-pressed="true"
oraria-selected="true"
instead ofis-active
Here’s a handy ARIA cheatsheet which came in handy whilst researching this article.
Implementation
Our reusable function currently assumes that everything passed to it via our newly renamed data-state
attribute is a class. It then acts accordingly based on either what’s defined in data-state-behaviour
or its default toggle
behaviour:
// Cycle through target elements
for(var c = 0; c < elemRef.length; c++) {
if(elemBehaviour === "add") {
if(!elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.add(elemClass);
}
}
else if(elemBehaviour === "remove") {
if(elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.remove(elemClass);
}
}
else {
elemRef[c].classList.toggle(elemClass);
}
}
Let’s tweak this slightly:
// Cycle through target elements
for(var c = 0; c < elemRef.length; c++) {
// Find out if we're manipulating aria-attributes or classes
var toggleAttr;
if(elemRef[c].getAttribute(elemState)) {
toggleAttr = true;
}
else {
toggleAttr = false;
}
if(elemBehaviour === "add") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, true);
}
else {
elemRef[c].classList.add(elemState);
}
}
else if(elemBehaviour === "remove") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].classList.remove(elemState);
}
}
else {
if(toggleAttr) {
if(elemRef[c].getAttribute(elemState) === "true") {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].setAttribute(elemState, true);
}
}
else {
elemRef[c].classList.toggle(elemState);
}
}
}
To support ARIA attributes, we’ve simply added a check to first see if the given ARIA attribute exists on the element, and if not, assume it’s a class and process it as before. This way we can support both ARIA attributes and classes to cover all eventualities. Also removed are the classList.contains()
checks, as in current spec classList.add()
and classList.remove()
are smart enough to account for this.
Keyboard Events
For a website to be considered accessible, it’s important that it can be easily navigated and interacted with through just the use of a keyboard. As far as the developer is concerned, this often involves the usage of the tabindex
attribute and leveraging keyboard events.
In most browsers, elements such as the anchor already have these properties by default. You can tab to them and when in focus they can be activated on the press of the enter key. However for many components built with a combination of semantic elements and divs, this is not the case.
Let’s make our reusable function pick up this slack by writing logic to automatically add a keyboard event to the trigger element so it can be activated – like an anchor – with a press of the enter key.
Implementation
At the moment, as the function logic is triggered by clicking an element with data-state
and data-state-element
attributes, everything is wrapped in a click
event listener:
elems[i].addEventListener("click", function(e){
// Function logic
});
As a press of the enter key is going to need to trigger the same function logic as a click, it makes sense to seperate out this logic into it’s own function so it can be triggered from either. We’ll call it processChange()
:
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
// e.which refers to the key pressed, 13 being the enter key.
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
In addition to the existing click
event listener, we’ve added an extra listener to react when the enter key is pressed. When a matching keypress
event occurs on a focused trigger element, it’s just a matter of running our new processChange()
function and passing the element.
You will also notice there’s no logic to automatically add a tabIndex
attribute. This is because it may conflict with any tabIndex
hierarchy already defined on the page and interfere with developer intent.
Example
Here’s a modified version of the accordion example from part 1, but fully updated to leverage ARIA attributes and keyboard events to make it a more accessible component. You can see the full reusable function as it now stands in the JavaScript panel.
See the Pen #7) Accessibility example by Luke Harrison (@lukedidit) on CodePen.
Accounting for elements added to DOM later
In Part 1, an issue was raised in the comments section:
I think this would have some issues for elements added to the DOM at a later point. In that case you would need to repeat assigning the click event. Am I correct?
That is correct! Any elements with data-state
and data-state-element
attributes added after the initial render of the DOM won’t have any event listeners assigned to them. So when they are clicked or swiped, nothing will happen.
Why? This is because in our Javascript, once the initial round of assigning event listeners to elements with data-state
and data-state-element
is complete, there’s no functionality in place to say “Hey! Watch out for any new elements withdata-state
and data-state-element
attributes and make them work.”
Implementation
To fix this, we’ll leverage something called MutationObserver
s. Whilst they can be explained much better in David Walsh’s great overview of the API, MutationObserver
s essentially allow us to track any nodes added or removed from the DOM (also known as “DOM mutations”).
We can setup one up like so:
// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var d = 0; d < mutation.addedNodes.length; d++) {
// Check if we're dealing with an element node
if(typeof mutation.addedNodes[d].getAttribute === 'function') {
if(mutation.addedNodes[d].getAttribute("data-state") && mutation.addedNodes[d].getAttribute("data-state-element")) {
// Create click and keyboard event listeners etc
}
}
}
});
});
// Define type of change our observer will watch out for
observer.observe(document.body, {
childList: true,
subtree: true
});
This is what our MutationObserver
is doing:
- Recording any DOM mutation to the body element which are immediate children
childList: true
or its decendentssubtree: true
- Checking if that DOM mutation is a new element node, rather than a text node
- If true, then check if the new element node has
data-state
anddata-state-element
attributes
The next step, assuming these 3 checks pass, would be to setup our click
and keypress
event listeners. Like with the implementation of keyboard events, let’s seperate out this setup logic into its own function so we can reuse it both on page load and when an element with data-state
and data-state-element
attributes is detected by our MutationObserver
.
We’ll call this new function initDataState()
.
// Init function
initDataState = function(elem){
// Add event listeners to each one
elems.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
// Add keyboard event for enter key to mimic anchor functionality
elems.addEventListener("keypress", function(e){
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
}
Then it’s just a matter of hooking everything up correctly:
// Run when DOM has finished loading
document.addEventListener("DOMContentLoaded", function() {
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state][data-state-element]");
// Loop through if any are found
for(var a = 0; a < elems.length; b++){
initDataState(elems[a]);
}
// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var d = 0; d < mutation.addedNodes.length; d++) {
// Check if we're dealing with an element node
if(typeof mutation.addedNodes[d].getAttribute === 'function') {
if(mutation.addedNodes[d].getAttribute("data-state")) {
initDataState(mutation.addedNodes[d]);
}
}
}
});
});
// Define type of change our observer will watch out for
observer.observe(document.body, {
childList: true,
subtree: true
});
});
Example
Click the “Add” button to insert more elements into the page (example Pen below):
See the Pen #8) Correctly set up new data-class elements when they are added to the DOM by Luke Harrison (@lukedidit) on CodePen.
Swipe support
At the moment, our reusable function is using click and keyboard events to trigger state logic. This is fine at desktop level, but on touch devices for some UI components (Such as closing a sliding navigation menu for instance) it’s often more useful to have this logic trigger on detection of a swipe.
Let’s build in optional swipe support for our reusable function. This will require adding a new data attribute to compliment our existing set:
data-state-swipe
The purpose of this new attribute is to allow us to define the swipe direction which should trigger our state logic. These directions should be:
up
right
down
left
Let’s also build in the option to specify if or not the swipe event should replace the click event, or if both should coexist. We can add a comma separated boolean to data-state-swipe
to trigger this behaviour:
true
– Swipe event listener replaces click event listenerfalse
– Swipe event listener and click event listener are both added (Default)
So for example, when the div
below detects a left swipe, the aria-expanded
attribute on js-elem
would be changed to true
. The swipe event listener would also in this instance replace the click event listener, as we’re passing true
in data-state-swipe
:
<div data-state="aria-expanded" data-state-element="js-elem" data-state-swipe="left, true" data-state-behaviour="add">
Now let’s make the changes.
Implementation
Swipe is managed in just the same way as you would manage clicks and keyboard input – via event listeners. To keep the article focused on our reusable function, I’ll be using a helper function called swipeDetect()
which will handle all the calculations required for accurate swipe detection. However feel free to use your own preferred method of detecting swipe direction in place of it.
We’re building swipe into our reusable function as another way of triggering function logic, so it makes sense that it should sit with the click and keyboard event listeners in initDateState()
and then trigger processChange()
once our requirements for a desired swipe direction are met.
Though we also have to account for the behaviour flag passed in data-state-swipe
that determines if swipe should replace click. Let’s refactor initDataState()
to add some scaffolding to properly support all of this:
// Init function
initDataState = function(elem){
// Detect data-swipe attribute before we do anything, as its optional
// If not present, assign click event like before
if(elem.getAttribute("data-state-swipe")){
// Grab swipe specific data from data-state-swipe
var elemSwipe = elem.getAttribute("data-state-swipe"),
elemSwipe = elemSwipe.split(", "),
swipeDirection = elemSwipe[0],
elemSwipeBool = elemSwipe[1],
currentElem = elem;
// If the behaviour flag is set to "false", or not set at all, then assign our click event
if(elemSwipeBool === "false" || !elemSwipeBool) {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Use our swipeDetect helper function to determine if the swipe direction matches our desired direction
swipeDetect(elem, function(swipedir){
if(swipedir === swipeDirection) {
// Run state function
processChange(currentElem);
}
})
}
else {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
};
These amends to initDataState
now give it 3 different outcomes:
- If there’s a
data-state-swipe
attribute on the trigger element, and its behaviour boolean is set totrue
, then only swipe and keyboard events are assigned. - If there’s a
data-state-swipe
attribute on the trigger element, but its behaviour boolean is set tofalse
, then swipe, click and keyboard events are all assigned. - If there’s no
data-state-swipe
attribute on the trigger element altogether, only click and keyboard event listeners are assigned.
Example
Here’s a very barebones example of the new swipe functionality in practice. Click the button to toggle the menu and then swipe right on the menu whilst on a touch device (or your preferred browser inspector) to close it. Simple.
See the Pen #9) Adding swipe support to our reusable function by Luke Harrison (@lukedidit) on CodePen.
Function Refinements
Finally, we’ll be looking into ways we can refine our reusable function to make it more efficient and easier to use.
Targeting the trigger element
Say for example I have an element named c-btn
which on click would need to toggle aria-pressed
on itself. With our reusable function as it stands, the HTML would look something like this:
<button class="c-btn" data-state="aria-pressed" data-state-element="c-btn" aria-pressed="false">
The problem here is that on click, aria-pressed
would be toggled on all instances of c-btn
everywhere, which isn’t the behaviour we’re looking for.
This was the problem which data-state-scope
was created to resolve. By scoping our data-state
instance to the nearest c-btn
(which in this case would be itself) then we are creating the desired toggle behaviour.
<button class="c-btn" data-state="aria-pressed" data-state-element="c-btn" data-class-scope="c-btn" aria-pressed="false">
Whilst the above snippet works fine, it’s a bit jarring having all these attributes all referencing the same c-btn
element. Ideally if data-state-element
and data-state-scope
aren’t defined, then the function should default to the element triggering it. This would allow easy targeting of our trigger element. Like so:
<button class="c-btn" data-state="aria-pressed" aria-pressed="false">
Implementation
data-scope-element
is currently a required attribute. If it isn’t present, the function will not be able to assign any event listeners. This is because in our reusable function as it stands, the initial scan of the document is looking for elements with both the data-scope
and data-scope-element
attributes:
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state][data-state-element]");
We need to tweak this so we’re only looking for elements with data-state
, as data-state-element
will shortly be relegated to an optional attribute.
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state]");
In addition, we need to add an if-statement to processChange()
which wraps around the retrieval of the data-state-element
value, as if it’s not present, the function will throw an error when trying to call getAttribute()
on something which doesn’t exist.
// Grab data-state-element list and convert to array
if(elem.getAttribute("data-state-element")) {
var dataStateElement = elem.getAttribute("data-state-element");
dataStateElement = dataStateElement.split(", ");
}
Next, let’s implement the logic which makes data-state-element
and data-state-scope
default to the trigger element if they are not explicitly defined. We can build on our previous amends to processChange()
and add an else
block to our data-state-element
check to manually declare our target element and scope.
// Grab data-state-element list and convert to array
// If data-state-element isn't found, pass self, set scope to self if none is present, essentially replicating "this"
if(elem.getAttribute("data-state-element")) {
var dataStateElement = elem.getAttribute("data-state-element");
dataStateElement = dataStateElement.split(", ");
}
else {
var dataStateElement = [];
dataStateElement.push(elem.classList[0]);
if(!dataStateScope) {
var dataStateScope = dataStateElement;
}
}
Another consequence of making data-state-element
no longer required is that in processChange()
, it’s length is used in the for
loop to make sure all the elements defined in data-state-element
receive their changes of state. This is the loop as it stands:
// Loop through all our dataStateElement items
for(var b = 0; b < dataStateElement.length; b++) {
[...]
}
Thankfully, all we need to do here is swap out our now optional data-state-element
attribute for our still required data-state
element as the base for this loop.
// Loop through all our dataStateElement items
for(var b = 0; b < dataState.length; b++) {
[...]
}
This is because in instances where multiple values have been passed to each data-state
attribute (For example: <div data-state="is-active, is-disabled" data-state-element="my-elem, my-elem-2" data-state-behaviour="add, remove">
) the length of the array which is derived from each of these is always going to match, so we’re always going to get the same amount of loops in the for
block.
Simplifying repeated values
Another improvement which we could make relates to assigning similar types of logic in a single data-state
use. Consider the below example:
<a data-state="my-state, my-state-2, my-state-3" data-state-element="c-btn, c-btn, c-btn" data-state-behaviour="remove, remove, remove" data-state-scope="o-mycomponent, o-mycomponent, o-mycomponent">
Whilst this a legitimate use of our reusable function, one thing you will notice is that we have a lot of repeated values in many of the data-state
attributes. Ideally, if we want to assign similar types of logic many times over, we should be able to just write a value once and have the function interpret that as repeat values.
For example, the below HTML snippet should perform the same action as the one above.
<a data-state="my-state, my-state-2, my-state-3" data-state-element="c-btn" data-state-behaviour="remove" data-state-scope="o-mycomponent">
Here is another example of what should be considered a valid data-state
use:
<a data-state="aria-expanded" data-state-element="c-menu, c-other-menu, c-final-menu" data-state-behaviour="remove, add">
Implementation
The first thing we need to consider is the for
loop in processChange()
which we last amended in the previous section. As it uses the length of data-state
as a base for it’s loop amount, implementing these changes would expose a bug in scenarios where we have one class being applied to many elements.
Consider the following:
<a data-state="my-state" data-state-element="c-btn, c-btn-2" data-state-behaviour="remove" data-state-scope="o-mycomponent">
What would happen here is because data-state
only has a single value, the for
loop in processChange()
would only loop a single time, meaning our intended logic for c-btn-2
would never be assigned.
To fix this, we need to compare data-state
and data-state-element
. Whichever has the most values then becomes the base for our loop. Like so:
// Find out which has the biggest length between states and elements and use that length as loop number
// This is to make sure situations where we have one data-state-element value and many data-state values are correctly setup
var dataLength = Math.max(dataStateElement.length, dataState.length);
// Loop
for(var b = 0; b < dataLength; b++) {
[...]
}
As for the rest of the implementation, it’s now a matter of adding logic in the for
loop for each attribute which says “If a matching value can’t be found, use the last valid one”.
Let’s use data-state
value as an example. Currently, the code in the for
loop which grabs the state value looks like:
// Grab state we will add
var elemState = dataState[b];
The problem now is if we have 3 data-state-element
values, but only 1 data-state
value, on loops 2 and 3 elemState
would be undefined
.
What we need to do is only redefine elemState
if we have a value to give it. Like so:
// Grab state we will add
// If one isn't found, keep last valid one
if(dataState[b] !== undefined) {
var elemState = dataState[b];
}
This would ensure elemState
would always have a value, including inheriting any previous values if one can’t initially be found.
Example
Here’s a final example showing all of our function refinements:
See the Pen #10) Allow easier targeting of self & general improvements by Luke Harrison (@lukedidit) on CodePen.
Closing
In this article, we’ve covered how to build upon the reusable function created in Part 1 to make it more accessible and easier to use.
In addition, we’ve also added swipe support for trigger elements and have made sure any data-state
elements added after the initial render of the DOM are no longer ignored.
As before, any comments or constructive feedback are welcome. I’ll leave you will the full reusable function which we’ve developed over the last 2 articles:
(function(){
// SWIPE DETECT HELPER
//----------------------------------------------
var swipeDetect = function(el, callback){
var touchsurface = el,
swipedir,
startX,
startY,
dist,
distX,
distY,
threshold = 100, //required min distance traveled to be considered swipe
restraint = 100, // maximum distance allowed at the same time in perpendicular direction
allowedTime = 300, // maximum time allowed to travel that distance
elapsedTime,
startTime,
eventObj,
handleswipe = callback || function(swipedir, eventObj){}
touchsurface.addEventListener('touchstart', function(e){
var touchobj = e.changedTouches[0]
swipedir = 'none'
dist = 0
startX = touchobj.pageX
startY = touchobj.pageY
startTime = new Date().getTime() // record time when finger first makes contact with surface
eventObj = e;
}, false)
touchsurface.addEventListener('touchend', function(e){
var touchobj = e.changedTouches[0]
distX = touchobj.pageX - startX // get horizontal dist traveled by finger while in contact with surface
distY = touchobj.pageY - startY // get vertical dist traveled by finger while in contact with surface
elapsedTime = new Date().getTime() - startTime // get time elapsed
if (elapsedTime <= allowedTime){ // first condition for awipe met
if (Math.abs(distX) >= threshold && Math.abs(distY) <= restraint){ // 2nd condition for horizontal swipe met
swipedir = (distX < 0)? 'left' : 'right' // if dist traveled is negative, it indicates left swipe
}
else if (Math.abs(distY) >= threshold && Math.abs(distX) <= restraint){ // 2nd condition for vertical swipe met
swipedir = (distY < 0)? 'up' : 'down' // if dist traveled is negative, it indicates up swipe
}
}
handleswipe(swipedir, eventObj)
}, false)
}
// CLOSEST PARENT HELPER FUNCTION
//----------------------------------------------
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// REUSABLE FUNCTION
//----------------------------------------------
// Change function
processChange = function(elem){
// Grab data-state list and convert to array
var dataState = elem.getAttribute("data-state");
dataState = dataState.split(", ");
// Grab data-state-behaviour list if present and convert to array
if(elem.getAttribute("data-state-behaviour")) {
var dataStateBehaviour = elem.getAttribute("data-state-behaviour");
dataStateBehaviour = dataStateBehaviour.split(", ");
}
// Grab data-scope list if present and convert to array
if(elem.getAttribute("data-state-scope")) {
var dataStateScope = elem.getAttribute("data-state-scope");
dataStateScope = dataStateScope.split(", ");
}
// Grab data-state-element list and convert to array
// If data-state-element isn't found, pass self, set scope to self if none is present, essentially replicating "this"
if(elem.getAttribute("data-state-element")) {
var dataStateElement = elem.getAttribute("data-state-element");
dataStateElement = dataStateElement.split(", ");
}
else {
var dataStateElement = [];
dataStateElement.push(elem.classList[0]);
if(!dataStateScope) {
var dataStateScope = dataStateElement;
}
}
// Find out which has the biggest length between states and elements and use that length as loop number
// This is to make sure situations where we have one data-state-element value and many data-state values are correctly setup
var dataLength = Math.max(dataStateElement.length, dataState.length);
// Loop
for(var b = 0; b < dataLength; b++) {
// If a data-state-element value isn't found, use last valid one
if(dataStateElement[b] !== undefined) {
var dataStateElementValue = dataStateElement[b];
}
// If scope isn't found, use last valid one
if(dataStateScope && dataStateScope[b] !== undefined) {
var cachedScope = dataStateScope[b];
}
else if(cachedScope) {
dataStateScope[b] = cachedScope;
}
// Grab elem references, apply scope if found
if(dataStateScope && dataStateScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(elem, dataStateScope[b]);
// Grab all matching child elements of parent
var elemRef = elemParent.querySelectorAll("." + dataStateElementValue);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-state-element and fits within scope
if(elemParent.classList.contains(dataStateElementValue)) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataStateElementValue);
}
// Grab state we will add
// If one isn't found, keep last valid one
if(dataState[b] !== undefined) {
var elemState = dataState[b];
}
// Grab behaviour if any exists
// If one isn't found, keep last valid one
if(dataStateBehaviour) {
if(dataStateBehaviour[b] !== undefined) {
var elemBehaviour = dataStateBehaviour[b];
}
}
// Do
for(var c = 0; c < elemRef.length; c++) {
// Find out if we're manipulating aria-attributes or classes
var toggleAttr;
if(elemRef[c].getAttribute(elemState)) {
toggleAttr = true;
}
else {
toggleAttr = false;
}
if(elemBehaviour === "add") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, true);
}
else {
elemRef[c].classList.add(elemState);
}
}
else if(elemBehaviour === "remove") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].classList.remove(elemState);
}
}
else {
if(toggleAttr) {
if(elemRef[c].getAttribute(elemState) === "true") {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].setAttribute(elemState, true);
}
}
else {
elemRef[c].classList.toggle(elemState);
}
}
}
}
},
// Init function
initDataState = function(elem){
// Detect data-swipe attribute before we do anything, as its optional
// If not present, assign click event like before
if(elem.getAttribute("data-state-swipe")){
// Grab swipe specific data from data-state-swipe
var elemSwipe = elem.getAttribute("data-state-swipe"),
elemSwipe = elemSwipe.split(", "),
direction = elemSwipe[0],
elemSwipeBool = elemSwipe[1],
currentElem = elem;
// If the behaviour flag is set to "false", or not set at all, then assign our click event
if(elemSwipeBool === "false" || !elemSwipeBool) {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Use our swipeDetect helper function to determine if the swipe direction matches our desired direction
swipeDetect(elem, function(swipedir){
if(swipedir === direction) {
// Run state function
processChange(currentElem);
}
})
}
else {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
};
// Run when DOM has finished loading
document.addEventListener("DOMContentLoaded", function() {
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state]");
// Loop through our matches and add click events
for(var a = 0; a < elems.length; a++){
initDataState(elems[a]);
}
// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var d = 0; d < mutation.addedNodes.length; d++) {
// Check if we're dealing with an element node
if(typeof mutation.addedNodes[d].getAttribute === 'function') {
if(mutation.addedNodes[d].getAttribute("data-state")) {
initDataState(mutation.addedNodes[d]);
}
}
}
});
});
// Define type of change our observer will watch out for
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}());
Article Series:
- Original article
- Managing State in CSS with Reusable JavaScript Functions (You are here!)
Managing State in CSS with Reusable JavaScript Functions – Part 2 is a post from CSS-Tricks
LEAVE A REPLY
You must be logged in to post a comment.