DailyJS

Let's Make a Framework: CSS Classes

Alex R. Young

Subscribe

@dailyjs

Facebook

Google+

tutorials frameworks css lmaf dom documentation

Let's Make a Framework: CSS Classes

Posted by Alex R. Young on .
Featured

tutorials frameworks css lmaf dom documentation

Let's Make a Framework: CSS Classes

Posted by Alex R. Young on .

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:

  1. Ensure the value passed in is a string
  2. Ensure the element is a valid node (ELEMENT_NODE)
  3. Use classList if available
  4. If not, set the className equal to the passed in value
  5. If the className has already been set, append the value
    with a space
  6. 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
.

References