Let's Make a Framework: CSS Classes

28 Apr 2011 | By Alex Young | Tags frameworks tutorials lmaf documentation dom css

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


blog comments powered by Disqus