Let's Make a Framework: CSS Classes
Welcome to part 60 of Let’s Make a Framework, the ongoing series about building a JavaScript framework.
These articles are tagged with lmaf. The project we’re creating is called Turing. Documentation is available at turingjs.com.
CSS Classes
I like to be able to easily change CSS classes on an element. Most JavaScript frameworks that I’ve encountered have an easy way of doing this. jQuery provides .addClass(), .removeClass(), .toggleClass() and .hasClass(). It’s a very simple API that’s easy to remember. Other frameworks work in a similar way.
Class Name Manipulation
Class names are manipulated using the DOM Level 2 className property: The HTMLElement interface: className attribute. This property is a string, so getting and setting individual class names requires a bit of string manipulation to implement a friendly API.
However… there’s also a new API called classList. This API looks a lot more like what web frameworks provide:
element.classList.add('className');
element.classList.remove('className');
element.classList.toggle('className');
element.classList.contains('className');
It might be working checking if the browser supports classList and using the native functions if available.
Implementing Adding Classes
I’d like this test to pass:
'test adding CSS classes': function() {
var element = turing.dom.get('#dom-test')[0];
// Invalid values should be ignored
turing.dom.addClass(element, null);
turing.dom.addClass(element, 10);
// This should change the className
turing.dom.addClass(element, 'newClass');
assert.equal(element.className, 'newClass');
turing.dom.addClass(element, 'class2');
assert.equal(element.className, 'newClass class2');
// Adding the same one twice should be ignored
turing.dom.addClass(element, 'class2');
assert.equal(element.className, 'newClass class2');
// Reset the value
element.className = '';
}
Adding classes should work like this:
- Ensure the value passed in is a string
- Ensure the element is a valid node (
ELEMENT_NODE) - Use
classListif available - If not, set the
classNameequal to the passed in value - If the
classNamehas already been set, append the value with a space - Make sure class names aren’t duplicated
What I came up with should do all of this with fairly easy to follow code:
/**
* Append CSS classes.
*
* @param {Object} element A DOM element
* @param {String} className The class name
*/
dom.addClass = function(element, className) {
if (!className || typeof className !== 'string') return;
if (element.nodeType !== nodeTypes.ELEMENT_NODE) return;
if (element.classList) return element.classList.add(className);
if (element.className && element.className.length) {
if (!element.className.match('\\b' + className + '\\b')) {
element.className += ' ' + className;
}
} else {
element.className = className;
}
};
The regular expression uses word boundaries to check if a class name has already been set — this will match spaces and the end of the string.
Removing Classes
Removing classes is pretty much the same. The tests are a bit more involved to make sure white space is handled correctly:
'test removing CSS classes': function() {
var element = turing.dom.get('#dom-test')[0],
testClasses = 'class1 class2 class3 class4';
// Invalid values should be ignored
turing.dom.removeClass(element, null);
turing.dom.removeClass(element, 10);
// Test a single class
turing.dom.addClass(element, 'newClass');
assert.equal(element.className, 'newClass');
turing.dom.removeClass(element, 'newClass');
assert.equal(element.className, '');
// Test multiple, making sure white space is as it should be
element.className = testClasses;
turing.dom.removeClass(element, 'class2');
assert.equal(element.className, 'class1 class3 class4');
element.className = testClasses;
turing.dom.removeClass(element, 'class1');
assert.equal(element.className, 'class2 class3 class4');
element.className = testClasses;
turing.dom.removeClass(element, 'class4');
assert.equal(element.className, 'class1 class2 class3');
// Reset the value
element.className = '';
}
I tried to use regular expressions again, replacing the old value then correcting white space. The second replace removes spaces that might get left at the start of the string:
/**
* Remove CSS classes.
*
* @param {Object} element A DOM element
* @param {String} className The class name
*/
dom.removeClass = function(element, className) {
if (!className || typeof className !== 'string') return;
if (element.nodeType !== nodeTypes.ELEMENT_NODE) return;
if (element.classList) return element.classList.remove(className);
if (element.className) {
element.className = element.className.
replace(new RegExp('\\s?\\b' + className + '\\b'), '').
replace(/^\s+/, '');
}
};
Chained API
I also added addClass and removeClass to the DOM chained API:
'test chained class manipulation API': function() {
turing('p').addClass('x1');
assert.ok(turing('p')[0].className.match(/\bx1\b/));
turing('p').removeClass('x1');
assert.ok(!turing('p')[0].className.match(/\bx1\b/));
}
These methods just loop through each element:
/**
* Add class names.
*
* @param {String} className A class name
* @returns {Object} `this`
*/
addClass: function(className) {
for (var i = 0; i < this.elements.length; i++) {
dom.addClass(this[i], className);
}
return this;
}
Conclusion
The fact that classList has appeared in some browsers makes this whole problem go away. I haven’t noticed any frameworks using classList — I’m not sure if there are any caveats to it. Also, my methods don’t cope with a list of class names in one string, which jQuery does (which is why jQuery’s implementation is more complicated than mine, see attributes.js).
This week’s code was commit e50328e.
