architecture html javascript labs

Decoupling JS from the DOM

There has been a big shift in the last few years toward javascript frameworks that dictate how we deal with the DOM. In this series I want to highlight the value in “progressive enhancement” style patterns when it comes to interacting with the DOM. Don’t get me wrong, I’m not saying the new ways are bad. There are still some takeaways from the, older, progressive enhancement style approach.

I have always appreciated the separation of concerns afforded by the “progressive enhancement” approach. Separating structure, behavior, and presentation makes sense from an organizational standpoint. The “progressive enhancement” approach of DOM scripting lends itself well to this. Ideally, the CSS and JS tend to have knowledge of the markup, but the markup does not have knowledge of the JS or CSS.

In the spirit of maintaining the separation I try to make my Javascript widgets HTML agnostic. For some this is overkill, but I want to underscore this approach as I have found it useful for many years. To achieve this just inject all the selectors needed by the component. jQuery plugins, Backbone views, etc. do this already for the base element. Taking that approach a step further is simple, just pass in the additional selectors needed for the widget to do its job.

Example:


  var $form = $('form');
  var selectableList = new $.SelectableList($form, {
    $submitElement: $form.find('input[type="submit"]'),
    $titleElement: $form.find('h2'),
    checkboxSelector: 'input[type="checkbox"]',
    $checkboxElements: $form.find('input[type="checkbox"]')
  });

This buys the developer a couple things:

1. It forces diligence around how many selectors, DOM elements, etc. are used by the widget.
2. It allows a component to be shared across interfaces with different markup.
3. It makes testing easier as it allows the simplest possible HTML fixture data.

Below is an example implementation and test file (there is a dependency on jQuery):


(function($){
  $.SelectableList = function(el, options){
    var base = this;

    base.$el = $(el);
    base.el = el;
    base.options = options;

    base.init = function(){
      base.toggleSubmit(false);
      base.bindEvents();
    };

    base.bindEvents = function() {
      base.$el.on('change', base.options.checkboxSelector, base.handleCheck);
    };

    base.handleCheck = function(e) {
      var count = base.getSelectedItemsCount()
      base.updateTitle(count);
      base.toggleSubmit(count);
    };

    base.toggleSubmit = function(enabled) {
      base.options.$submitElement.prop('disabled', !enabled);
    };

    base.updateTitle = function(count) {
      base.options.$titleElement.html(count + ' members selected')
    };

    base.getSelectedItemsCount = function() {
      return base.options.$checkboxElements.filter(':checked').length;
    };
  };
})(jQuery);

describe("$.SelectableList", function() {
  var selectableList,
      $titleElement,
      $submitInput

  beforeEach(function() {
    var fixtureHTML = "<form id='test_form'>" +
      "<h2>Select Tribe members</h2>" +
      "<ul>" +
        "<li><input type='checkbox' />Phife</li>" +
        "<li><input type='checkbox' />Q-tip</li>" +
        "<li><input type='checkbox' />Ali</li>" +
        "<li><input type='checkbox' />Jarobi</li>" +
      "</ul>" +
      "<input type='submit' value='Delete selected tribe members' />"
    "</form>";

    $('body').append(fixtureHTML);

    $titleElement = $('#test_form h2');
    $submitInput = $('input[type="submit"]')

    var $form = $('form#test_form');
    selectableList = new $.SelectableList($form, {
      $titleElement: $form.find('h2'),
      $submitElement: $form.find('input[type="submit"]'),
      $checkboxElements: $form.find('input[type="checkbox"]'),
      checkboxSelector: 'input[type="checkbox"]'
    });
  });

  afterEach(function() {
    $('#test_form').remove();
  });

  describe('#init', function() {
    beforeEach(function() {
      selectableList.init();
    });

    it('disables the submit element', function() {
      expect($submitInput.is(':disabled')).toBe(true)
    });
  });

  describe('when more than one item has been selected', function() {
    beforeEach(function() {
      selectableList.init();
      $('ul li:first-child input').prop('checked', true).change();
      $('ul li:last-child input').prop('checked', true).change();
    });

    it('updates the title', function() {
      expect($titleElement.text()).toEqual('2 members selected');
    });

    it('enables the submit element', function() {
      expect($submitInput.is(':disabled')).toEqual(false);
    });
  });
});

View on github.