Cross-Browser Widgets

Building DHTML widgets for the general web is hard. By "general web", I mean the widget will be used on a web page that is accessible to any user with a web browser. The user may be physically disabled and using a screen reader. The web browser may be on a modern cell phone with archaic JavaScript support. Any combination of images, CSS, JavaScript in the browser may be turned off. The browser may be Internet Explorer. Building for the general web is building for 10000+ browser/operating system/configuration combinations. We cannot code specifically for each combination and we cannot test all the combinations. We must rely on state of the art practices:

  • separation of concerns (i.e. HTML content, CSS presentation, JavaScript behavior)
  • feature detection (i.e. not browser sniffing)
  • progressively enhancements (a.k.a. graceful degradation)

The most difficult part of building a widget for the general web is coordinating the timing of all feature tests and DOM manipulation that need to happen when the page loads. An HTML page is rendered incrementally as it arrives. The page may be visible to the user at various stages of loading. We must ensure the page looks good at all points in the process for all visitors. Not every user will have the same experience when visiting the page. Some users will see widgets and others will see plain HTML if their browsers cannot support the widgets. Our goal is that all users will have a good experience by providing a pleasing visual and interactive page based on the browsers capabilities and by avoiding any JavaScript errors being thrown.

Unfortunately many front-end developers throw there hands up in frustration and convince the boss that "supporting a few recent versions of four (or three (or two)) modern browser with images, CSS and JavaScript turned on is good enough." Then they pull out the old navigator.userAgent (or a JavaScript library that uses it) and other hacks and before you know it their web page is broken for something like 15% of visitors. If the page is an purchase order form, that is a big loss in potential sales. It would have been better if the developers had just left the page as static HTML.

I have read many articles about individual the pieces in the puzzle that is building a widget for the general web. I have never seen an article that really tries to put them together to build "the perfect widget". This is my attempt.

Tabbed Panes

Tabbed panes are one of the few reoccurring and foundational DHTML user interface elements. The basic idea is clicking a tab shows a particular pane of content and only one pane can be shown at a time. People still seem to get pretty excited when they see a tabbed pane disguised in a slight variation or with a bit of animation. Frequently, I am asked to build pages that have little widgets that boil down to "just another tabbed pane" and so tabbed panes make a great case study for widget programming. In experiments alone, I have programmed a basic tabbed pane widget at least 20 different ways to try new libraries or ways of organizing a widget's feature test and program design.

Below are three variants on the tabbed pane widget theme that are on popular web sites on the general web.

Google Maps has a relatively standard tabbed pane with the tabs across the bottom.

Google tabbed pane

Yahoo!'s front page features the 6-in-1 with two rows of tabs and animation.

Yahoo six in one

Apple's site includes many accordions that activate by hovering each "tab".

Apple accordion

Specifications

In order to build something we must know what we are building. Here is what I'm building in this article. I think this is a fairly common set of requirements. The three examples above could be built to these requirements.

  • The tabbed pane must be enabled and work in recent versions of recent browsers (including Internet Explorer 5.5+, Firefox 1.0+, Opera 8.0+, and Safari 1.3+) to the most modern browsers.
  • The tabbed pane must leave other browsers with static HTML.
  • The tabbed pane must not cause any JavaScript errors in browser from Internet Explorer 4 and Netscape Navigator 4 to the most modern browsers.
  • The tabbed pane must look good while the page incrementally loads.
  • There may be zero, one or more tabbed panes in the page.
  • The number of tabbed panes in the page is not dynamic. That is tabbed panes are not added to the page while the user visits.
  • The number of tabs in each tabbed pane is not dynamic.

The HTML that is to be enhanced as a tabbed pane is easy for the content author to maintain and works as static HTML in any browser.

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
  "http://www.w3.org/TR/html4/strict.dtd">

<html>
<head>

  <title>A Tabbed Pane</title>

  <script src="lib.js" type="text/javascript"></script>
  <script src="tabbedPane.js" type="text/javascript"></script>
  
</head>
<body>

  <div class="sections">

    <div class="section first">
      <h2>One</h2>
      <p>
        Lorem ipsum dolor sit amet, consectetur adipisicing elit,
        sed do eiusmod tempor incididunt ut labore et dolore magna
        aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
      </p> 
    </div>

    <div class="section">
      <h2>Two</h2>
      <p>
        Duis aute irure dolor in reprehenderit in voluptate
        velit esse cillum dolore eu fugiat nulla pariatur.
      </p>
    </div>

    <div class="section">
      <h2>Three</h2>
      <p>
        Excepteur sint occaecat cupidatat non proident, sunt
        in culpa qui officia deserunt mollit anim id est laborum.
      </p>
    </div>

  </div>

</body>
</html>

Many tabbed pane libraries require an list to be maintained at the top of the HTML for the tabs. Although thinking of this as a table of contents makes it semantically ok, it is not very maintainable by the content author. Either the id attributes need to somehow connect the tab and it's pane, or the order of the tabs in the list and the sections need to stay in sync. In the HTML above, we can use the content of the h2 element in each section as the content of each tab. Keeping the tab with it's pane in the HTML keeps it much simpler for everyone. There are many choices you can make for this part of the design.

Note that we are building for an HTML page as XHTML is not a viable technology for the general web. An XHTML solution would be different because XHTML has its own set of oddities.

JavaScript Libraries

Currently there is no mainstream library we can simply download off the web to help us build for the general web. For all the innovation and good parts in the mainstream libraries, they all seem to use navigator.userAgent and other sniffs and hacks. This means the libraries produce broken pages in browsers that are only slightly older or more exotic than the "supported set". I hope this unfortunate situation will change soon.

We don't need a very extensive library for a tabbed pane so we can just whip up a little illustrative library ourselves in no time from spare parts lying around the shop.

  • LIB.isHostObject(object, propertyName)
  • LIB.isHostMethod(object, propertyName)
  • LIB.isHostCollection(object, propertyName)
  • LIB.getAnElement()
  • LIB.getDocumentElement()
  • LIB.addListener(element, eventType, handler, options)
  • LIB.preventDefault(event)
  • LIB.hasClass(element, className)
  • LIB.addClass(element, className)
  • LIB.removeClass(element, className)
  • LIB.querySelector(selector, optionalRootElement)
  • LIB.forEach(arrayLikeObject, fun)
  • LIB.filter(arrayLikeObject, fun)

I think most of the above library functions will be familiar to browser script programmers. The first five may be new. I wrote an article on the isHost* functions and how they can be used for feature testing. The getAnElement and getDocumentElement functions are also useful when feature testing before enabling the widet. The last three are not strictly necessary but they do make application code shorter which is good.

Each library function is only defined if the feature testing in the library has inferred that the function will execute without error and with the expected behavior in the particular browser. Not all of the above functions work in all browsers. This means that in our application code we must test for the existence of each function before we call it.

The JavaScript library code is included at the end of this article for completeness and nitpicking if it is in error. Really I want to focus on what the application programmer writes because hopefully it won't be long and there will be good libraries you can download from the web to do the job. I know of a few such efforts.

Widget Design Overview

The major driving factors in the widget code's design are feature testing and HTML's incremental loading.

We must always feature test every host and library feature we use. If we use any recent language features we must test for those also. The word "recent" is a bit vague but our specification of no thrown errors in IE4 and NN4 help guide us. This also limits some JavaScript syntax available to us. There are modern cell phones that throw syntax errors if a script uses === or try/catch, for example. A sad state of affairs but reality is what it is and this doesn't limit our ability to write successful scripts.

The browser's incremental rendering of HTML has caused many problems for script authors but it is a good feature for impatient visitors. Sometimes I can read the page and click a link before all the big images in a page are even loaded.

A wrench in the works here is that even though the page renders incrementally, we cannot safely manipulate the DOM or test CSS support while the page loads. Some of the feature tests we must make before inferring the browser will support our tabbed pane widget require us to add test elements to the page and check the necessary CSS works. We must wait until the window.onload event fires.

We have competing constraints. We want the page to look good while it loads but we cannot finish testing for support for our widget until window.onload. I think this set of constraints has made it difficult for many to embrace the ideas of feature testing, unobtrusive JavaScript and progressive enhancements. Some examples I have seen of these good techniques have had pages with less than desirable appearance. While the page is incrementally loading, it looks like a long static HTML page. When the window.onload event fires, the DOM is ready and the browser features are tested then the DOM is manipulated and className attributes are added to elements. When the className attributes are added then the CSS in the stylesheets starts to work. For a widget like a tabbed pane, that means the long HTML of all the panes, that are all visible while the page loads, suddenly "suck up" into the widget and only one pane is shown. This is usually very jumpy and annoying if the page has taken a long time to load. A visitor could be reading well into the nth pane and then it suddenly disappears. It seems people worry more about jumpy screens than feature testing.

Part of the purpose of this article is to propose a solution to this jumpy page problem. When the page first loads we can feature test some of the features needed for the widget and the features we need to "get out of trouble". If these tests pass, we dynamically write a stylesheet into the page that tentative styles the tabbed panes. When window.onload fires and we can complete the feature tests for the widget the tests may pass. If they do pass we enable the widget with JavaScript listeners and give some elements in the page special className attributes so the CSS makes the widget look like a tabbed pane. If the final feature tests fail we need to "get out of trouble" and style what were to be the tabbed pane widgets so they are pleasing as static HTML.

The plan is to have the following pseudo code execute in the document head element before incremental rendering begins.

if (initial feature tests pass) {
  // The widget might work
  add tentative styling
  window.onload = function() {
    if (final feature tests pass) {
      // The widget will work
      DOM manipulation
      enlivenment of DOM elements in widget
      add enabled styling
    }
    else {
      // The widget won't work
      add disabled styling
    }
  }
}

How the tentative styling will look depends on the application. It may be best to hide the entire widget until the final tests. The widget could be shown differently depending if the widget will be enabled or disable.

In this article's example, the tentative styling will show the first pane of the widget. That is of course, if CSS is enabled in the browser. If it is not enabled, all the panes will be shown. We will test for CSS support in the final feature testing when the window.onload event fires. There are many browser features we must juggle here!

The Stylesheet

You will clearly note that I put only some very basic styling on the widget. This was an effort to ensure the mechanics of the example are completely clear. Feel free to spruce it up all you like.

/* This file will only be loaded if the inital feature
   tests pass and there is a chance that tabbed panes
   may be supported by the browser. */

/* if initial feature tests pass give tentative styling */
.sections .section {
  display:none;
}
.sections .first {
  display:block;
}

/* if final feature tests for tabbed panes fail */
.disabledTabbedPane .section {
  display:block;
}

/* if final feature tests for tabbed panes pass */
.enabledTabbedPane .tabs li.current {
  background:red;
}

.enabledTabbedPane div.current {
  border:1px solid black;
  margin-top:0;
  padding:1em;
}

.enabledTabbedPane .tabs {
  margin-bottom:0;
  margin-left:0;
  padding-left:0;
}

.enabledTabbedPane .tabs li {
  display:inline;
  margin-left:0;
  padding:0 1em;
}

You can see above that there are three sections: tentative, enabled and disabled. The visual designers can play all they like and make the widget flashy to impress the visitors.

There are no lines in the CSS that say display:none; and display:block; to toggle panes when the widget is enabled. This is done in the JavaScript by directly modifying the element.style.display property with string values of 'none' and 'block'.

Having styling in the JavaScript is debatable. Pure separatists argue the JavaScript should only manipulate the className attributes and the CSS should apply the styles. That is a great idea until you try to make an animation that way: .frame1{height:1px} .frame2{height:2px} etc. For this example, the reason the essential styling for the widget is in the JavaScript is that way it has the highest priority in the CSS cascade. Some visual design folks can become quite confused when told about the cascade and I simply don't want to worry about that. It is literally trivial to change the JavaScript code below so that the JavaScript only manipulates the className attributes.

The Code

We have finally made it to the guts of the article. You will see that the code is organized exactly like in the pseudo-code earlier.

// initial feature tests
    // Check that the library loaded
if (typeof LIB == 'object' &&
    LIB &&
    // Test for library functions I use directly
    LIB.isHostObject &&
    LIB.isHostMethod &&
    LIB.isHostCollection &&
    LIB.getAnElement &&
    LIB.getDocumentElement &&
    LIB.addListener &&
    LIB.preventDefault &&
    LIB.hasClass &&
    LIB.addClass &&
    LIB.removeClass &&
    LIB.querySelector &&
    LIB.filter &&
    LIB.forEach &&
    // Test for host objects and methods I use directly
    LIB.isHostObject(this, 'document') &&
    LIB.isHostMethod(this.document, 'write') &&
    LIB.isHostMethod(this.document, 'createTextNode') &&
    LIB.isHostMethod(this.document, 'createElement') &&
    (function() {
      var el = LIB.getAnElement();
      return LIB.isHostObject(el, 'style') &&
             typeof el.style.display == 'string' &&
             LIB.isHostMethod(el, 'appendChild') &&
             LIB.isHostMethod(el, 'insertBefore') &&
             LIB.isHostObject(el, 'firstChild') &&
             LIB.isHostCollection(el, 'childNodes') &&
             typeof el.innerHTML == 'string';
    })()) {

  (function() {
    
    var doc = this.document;
    
    // configuration
    var tabbedPaneCssUrl = 'tabbedPane.css',
        // optional depending on content of tabbed pane and 
        // desired printing effect.
        // tabbedPanePrintCssUrl = 'tabbedPanePrint.css',
        enabledTabbedPaneClassName = 'enabledTabbedPane',
        disabledTabbedPaneClassName = 'disabledTabbedPane',
        currentClassName = 'current',
        tabGroupTagName = 'ul',
        tabGroupClassName = 'tabs',
        tabTagName = 'li',
        defaultTabText = 'tab',
        paneTagName = 'div',
        tabbedPaneGroupClassName = 'sections',
        paneGroupTagName = 'div',
        tabbedPaneClassName = 'section',
        showPaneDisplay = 'block',
        hidePaneDisplay = 'none';

    var showPane = function(pane) {
      pane.style.display = showPaneDisplay;
      LIB.addClass(pane, currentClassName);
    };
    var hidePane = function(pane) {
      pane.style.display = hidePaneDisplay;
      LIB.removeClass(pane, currentClassName);
    };
    var makeTabCurrent = function(tab) {
      LIB.addClass(tab, currentClassName);
    };
    var makeTabNotCurrent = function(tab) {
      LIB.removeClass(tab, currentClassName);
    };
    
    var enliven = function(current, tab, pane) {
      LIB.addListener(tab, 'click', function(e) {
        LIB.preventDefault(e);
        
        // avoid potential flicker if user clicks the current tab
        if (tab == current.tab) {
          return;
        }
        makeTabNotCurrent(current.tab);
        hidePane(current.pane);
        current.tab = tab;
        current.pane = pane;
        makeTabCurrent(tab);
        showPane(pane);
      });
    };

    var init = function(widgetEl) {
      LIB.addClass(widgetEl, enabledTabbedPaneClassName);
      var tabs = doc.createElement(tabGroupTagName),
          first = true,
          current,
          tab,
          heading;
      LIB.addClass(tabs, tabGroupClassName);
      LIB.forEach(
        LIB.filter(
               widgetEl.childNodes,
               function(node) {
                 return LIB.hasClass(node, tabbedPaneClassName);
               }),
        function(pane) {
          tab = doc.createElement(tabTagName);
          if (first) {
            current = {tab:tab, pane:pane};
            makeTabCurrent(tab);
            showPane(pane);
            first = false;
          }
          else {
            hidePane(pane);
          }
          enliven(current, tab, pane);
          heading = LIB.querySelector('h2', pane)[0];
          tab.innerHTML = '<a href="#">' +
                          (heading ?
                            heading.innerHTML :
                            defaultTabText) +
                          '</a>';
          tabs.appendChild(tab);
        });
      widgetEl.insertBefore(tabs, widgetEl.firstChild);
    };

    // Test that a pane really appears and disappears.
    // This test uses a dummy tabbed pane temporarily 
    // inserted into the page. It is one of 
    // the largest granularity test possible to determine
    // the tabbed pane will work.
    // 
    // Tests that CSS is enabled, the necessary
    // CSS is supported and that there are no !important rules
    // that will interfere.
    var supportsDisplayCss = function() {
      var outer = doc.createElement(paneGroupTagName),
          inner = doc.createElement(paneTagName);

      if (LIB.isHostObject(doc, 'body') &&
          LIB.isHostMethod(doc.body, 'removeChild')) {

        LIB.addClass(outer, enabledTabbedPaneClassName);
        LIB.addClass(inner, tabbedPaneClassName);
        inner.innerHTML = '.';
        outer.appendChild(inner);
        doc.body.appendChild(outer);
        showPane(inner);
        var doesSupport;
        // wait until elements are attached
        // to the DOM before examining them.
        if (typeof outer.offsetHeight == 'number') {
          var height = outer.offsetHeight;
          hidePane(inner);
          doesSupport = (height > 0 && outer.offsetHeight == 0);
        }
        doc.body.removeChild(outer);
        return doesSupport;
      }
      return false;
    };

    // We don't know for sure at this point that the tabbed pane
    // will work. We have to wait for the DOM is ready to finish
    // the tests. We do know we can give the pages some style to use
    // during the page load because we can "get out of trouble"
    // when window.onload fires. This is
    // because the functions used to get out of trouble
    // have been feature tested.
    doc.write('<link href="'+tabbedPaneCssUrl+'"' +
              ' rel="stylesheet" type="text/css">');

    // optional depending on content of tabbed pane and 
    // desired printing effect.
    // doc.write('<link href="'+tabbedPanePrintCssUrl+'" media="print"' +
    //           ' rel="stylesheet" type="text/css">');

    // "this" refers to the window object.
    LIB.addListener(this, 'load', function() {
      // Cannot test that CSS support works until the DOM is ready.
      var widgetEls = LIB.querySelector('.'+tabbedPaneGroupClassName);
      if (supportsDisplayCss()) {
        LIB.forEach(widgetEls, init);
      }
      else {
        // "get out of trouble"
        LIB.forEach(widgetEls,
          function(el) {
            LIB.addClass(el, disabledTabbedPaneClassName);
          });
      }
    });

  })();
  
}

You can see much of this code is related to feature testing. That will frequently be the case but it is prerequisite to enabling a widget on a page for the general web. You could create a helper function that checks for library functions. You could make certain library functions into groups and ask the library if the whole group is enabled. You could make the whole library all or nothing. I think the last option has larger-than-desirable granularity.

The supportsDisplayCss is likely to be the most novel test in the code. It creates some stub tabbed pane HTML and inserts it into the page and manipulates it to ensure the widgets will really work. This type of testing eliminates most arguments that you cannot feature test rendering and CSS support. You can test most, if not all rendering. In this example, if the user has any !important rules in a user stylesheet that will break the widget, then the tabbed pane widgets will not be enabled.

The visitor can use tab and enter keys to navigate the page.

There are many ways to "architect" the init function. This is all personal preference and choosing depends heavily on the requirements. I have decided to use a very light design with function closures attached as listeners to the tabs. Each closure knows its tab and related pane. All of these closures share the current object so one tab and turn off the current tab. After programming tabbed panes in 20+ different ways, this is the way that feels like a sports car to me. I just look at it and see a minimalistic, finely tuned machine. If you prefer OOP you could build the widget that way with a TabbedPane constructor function. The OOP versions work just fine but always feel like tanks to me; however, tanks are great in some situations. For this widget, the elegant closure system works well.

Try the tabbed pane demo.

Summary

Build a robust widget for the general web is hard work and the example above has room for improvement. The level of feature testing could be increased at the cost of file size. For example, on comp.lang.javascript, David Mark suggested that the browser could run out of resources and a call to createElement could return null. He also admitted this may be overly paranoid. I think it is and by testing for the existance of document.createElement I've inferred that calls to it will work as expected.

I've tried the tabbed pane in all the browsers I have and it is either enabled an works or it is disabled and doesn't throw errors. I suspect there is a browser out there somewhere that will throw an error. That is just the nature of the cross-browser challenge. In a perverse masochistic way, I somewhat hope someone can find a browser in which this widget breaks. That is one way we can continue to improve.

Acknowledgements

I would like to thank the contributors to Usenet's comp.lang.javascript for discussions over the past couple years about feature detection and particularly to David Mark for reviewing the code in this article several times with helpful suggestions.

Appendix

Below I am dumping the library code for completeness. This code works around many browser bugs and those discussions are beyond the scope of the article but if you see a problem feel free to comment.

var LIB = {};

// Some array extras for the app developer.
// These are not used within the library
// to keep library interdependencies low.
// These extras don't use the optional 
// thisObject argument of native JavaScript 1.6
// Array.prototype.filter but that can easily
// be added here at the cost of some file size.
// Not using the native Array.prototype.filter
// because want to be able to send array-like
// objects to these functions.
LIB.filter = function(a, f) {
  var rs = [];
  for (var i=0, ilen=a.length; i<ilen; i++) {
      if (typeof a[i] != 'undefined' && f(a[i])) {
        rs[rs.length] = a[i];
      }
  }
  return rs;
}; 

LIB.forEach = function(a, f) {
  for (var i=0, ilen=a.length; i<ilen; i++) {
    if (typeof a[i] != 'undefined') {
      f(a[i]);
    }
  }
};

// ---------------------------------------------------

(function() {
  
  // Use for testing if a DOM property is present.
  // Use LIB.isHostCollection if the property is
  // a collection. Use LIB.isHostMethod if
  // you will be calling the property.
  //
  // Examples:
  //   // "this" is global/window object
  //   LIB.isHostObject(this, 'document');
  //   // "el" is some DOM element
  //   LIB.isHostObject(el, 'style');
  var isHostObject = function(o, p) {
    return !!(typeof(o[p]) == 'object' && o[p]);
  };
  LIB.isHostObject = isHostObject;

  // Use for testing if DOM collects are present
  // Some browsers make collections callable and
  // have a typeof 'function'.
  //
  // Examples:
  //   // since tested document as property of "this"
  //   // need to refer to it as such
  //   var doc = this.document;
  //   LIB.isHostCollection(doc, 'all');
  //   // "el" is some DOM element
  //   LIB.isHostCollection(el, 'childNodes');
  var isHostCollection = function(o, m) {
    var t = typeof(o[m]);
    return (!!(t=='object' && o[m])) ||
           t=='function';
  };
  LIB.isHostCollection = isHostCollection;

  // Use for testing a DOM property you intend on calling.
  // In Internet Explorer, some ActiveX callable properties
  // have a typeof "unknown". IE returns "object" for typeof
  // operations on callable host objects, but other browsers
  // (e.g. Safari, Opera) return "function."
  //
  // Examples:
  //   // since tested document as property of "this"
  //   // need to refer to it as such
  //   var doc = this.document;
  //   LIB.isHostMethod(doc, 'createElement');
  //   // "el" is some DOM element
  //   LIB.isHostMethod(el, 'appendChild');
  var isHostMethod = function(o, m) {
    var t = typeof(o[m]);  
    return t=='function' ||
           !!(t=='object' && o[m]) ||
           t=='unknown';
  };
  LIB.isHostMethod = isHostMethod;

  if (!(isHostObject(this, 'document'))) {
    return;
  }
  var doc = this.document;
  
  if (isHostObject(doc, 'documentElement')) {
    var getAnElement = function(d) {
      return (d || doc).documentElement;
    };
    LIB.getAnElement = getAnElement;
    LIB.getDocumentElement = getAnElement;
  }
  
  if (getAnElement &&
      typeof getAnElement().className == 'string') {
    // The RegExp support need here 
    // has been available since NN4 & IE4
    
    var classRegExpCache = {};
    
    // do not include the global flag on the regexp
    // as the regexp is being used repeatedly with 
    // RegExp.prototype.test(). See
    // http://groups.google.com/group/comp.lang.javascript/msg/a9523f0b06c4bdcc
    LIB.makeClassRegExp = function(className) {
      return classRegExpCache[className] = 
               new RegExp('(^|\\s+)' + className + '(\\s+|$)');
    };
    
    LIB.hasClass = function(el, className) {
      return (classRegExpCache[className] ||
              LIB.makeClassRegExp(className)).test(el.className);
    };

    LIB.addClass = function(el, className) {
      if (LIB.hasClass(el, className)) {
        return;
      }
      el.className = el.className + ' ' + className;
    };

    LIB.removeClass = function(el, className) {
      el.className = el.className.replace(
        (classRegExpCache[className] ||
         LIB.makeClassRegExp(className)), ' ');
      // in case of multiple
      if (LIB.hasClass(el, className)) {
        LIB.removeClass(el, className);
      }
    };
  }
  
  // Letting IE 5.x down the degradation path is not a tragedy
  // and could easily be justified. Support for IE 5 is
  // included here as an illustration of a more complex feature
  // detection problem.
  //
  // IE 5.0 & 5.5 don't support getElementsByTagName('*') but can
  // fallback to using document.all or element.all.
  // Safari 1.0 does not support
  // getElementsByTagName('*') and doesn't have document.all
  if (LIB.getDocumentElement &&
      (function() {
        var el = LIB.getDocumentElement();
               // Test both interfaces in the DOM spec
        return isHostMethod(doc, 'getElementsByTagName') &&
               ((doc.getElementsByTagName('*').length > 0) ||
                (isHostCollection(doc, 'all') &&
                 doc.all.length > 0)) &&
               isHostMethod(el, 'getElementsByTagName') &&
               (isHostMethod(el, 'getElementsByTagName') ||
                (isHostCollection(el, 'all') &&
                 el.all.length > 0));
      })()) {
    // One possible implementation for developers 
    // in a situation where it is not a problem that
    // IE5 thinks doctype and comments are elements.
    // Can workaround that if necessary.
    LIB.getEBTN = function(tag, root) {
      root = root || doc;
      var els = root.getElementsByTagName(tag);
      if (tag == '*' &&
          !els.length) {
        els = root.all;
      }
      return els;
    };
  }
  
  if (isHostMethod(doc, 'getElementById')) {
    // One possible implementation for developers
    // not troubled by the name and id attribute
    // conflict in IE
    LIB.getEBI = function(id, d) {
      return (d || doc).getElementById(id);
    };
  }
  
  if (LIB.getEBTN &&
      LIB.getEBI &&
      getAnElement &&
      (function() {
        var el = getAnElement();
        return typeof el.nodeType == 'number' &&
               typeof el.tagName == 'string' &&
               typeof el.className == 'string' &&
               typeof el.id == 'string' 
       })()) {

    // One possible selector compiler implementation
    // that can handle selectors with a tag name,
    // a class name and an id.
    //
    // use memoization for efficiency
    var selectorCache = {};
    var compile = function(s) {
      if (selectorCache[s]) {
        return selectorCache[s];
      }

      var m,    // regexp matches
          tn,   // tagName in s
          id,   // id in s
          cn,   // className in s
          f;    // the function body

      m = s.match(/^([^#\.]+)/);
      tn = m ? m[1] : null;

      m = s.match(/#([^\.]+)/);
      id = m ? m[1] : null;

      m = s.match(/\.([^#]+)/);
      cn = m ? m[1] : null;

      f = 'var i,els,el,m,ns=[];';
            
      if (id) {
        f += 'if (!d||(d.nodeType==9||(!d.nodeType&&!d.tagName))){'+
               'els=((el=LIB.getEBI("'+id+'",d))?[el]:[]);' +
               ((!cn&&!tn)?'return els;':'') +
             '}else{' +
               'els=LIB.getEBTN("'+(tn||'*')+'",d);' +
             '}';
      }
      else {
        f += 'els=LIB.getEBTN("'+(tn||'*')+'",d);';
      }

      if (cn) {
         // note that cn cannot contain unescaped "
        f += 'var re=LIB.makeClassRegExp("' + cn + '");';
      }

      if (id || cn) {
        f += 'i=els.length;' +
             'while(i--){' +
               'el=els[i];' +
               'if(';
            if (id) {
              f += 'el.id=="'+id+'"';
            }
            if ((cn||tn) && id) {
              f += '&&'; 
            }
            if (tn) {
              f += 'el.tagName.toLowerCase()=="' + tn + '"';
            }
            if (cn && tn) {
              f += '&&'; 
            }
            if (cn) {
              f += '((m=el.className)&&re.test(el.className))';
            }
          f += '){' +
                 'ns[ns.length]=el;' +
            '}' +
          '}';        
          
          f += 'return ns.reverse();';
      }
      else {
        f += 'return els;';
      }

      // http://elfz.laacz.lv/beautify/
      //console.log('function f(d) {' + f + '}');

      f = new Function('d', f);
      selectorCache[s] = f;
      return f;
    }

    LIB.querySelector = function(selector, rootEl) {
      return (compile(selector))(rootEl);
    };

  }
  
})();

// Events ----------------------------------------------------------

(function() {
  
  if (Function.prototype.apply && // IE 5 doesn't have apply()
      LIB.getAnElement &&
      Array.prototype.slice 
      // uncomment next line if using
      // the try-catch below 
      //&& this.setTimeout
      ) {
      
      var global = this,
          anElement = LIB.getAnElement(),
          listeners = [];

      var wrapHandler = function(element, handler, options) {
        options = options || {};
        var thisObj = options.thisObj || element;
        return function(e) {
          return handler.apply(thisObj, [e || global.event]);
        };
      };

      var createListener = function(element, eventType,
                                             handler, options) {
        return {
          element: element,
          eventType: eventType,
          handler: handler,
          wrappedHandler: wrapHandler(element, handler, options)
        };
      };

      var callListeners = function(listeners, e) {
        // Make a local copy incase one listener
        // adds more listeners to the array. 
        // Worst case is a listener adding itself
        // and creating an infinte loop.
        listeners = listeners.slice(0);
        
        for (var i=listeners.length; i--; ) {
          var listener = listeners[i];
          if (listener && listener.wrappedHandler) {
            // Uncomment try-catch if you do not need to support
            // old browsers or some modern cell phones.
            // The try-catch will ensure all handlers run.
            // Also uncomment test for global.setTimeout above
            //try {
            listener.wrappedHandler(e);
            //}
            //catch (err) {
            //  (function(err) {
            //    global.setTimeout(function() {throw err}, 10);
            //  })(err);
            //}
          }
        }
      };


    // BEGIN DOM2 event model
    if (LIB.isHostMethod(global, 'addEventListener') &&
        LIB.isHostMethod(anElement, 'addEventListener')) {
      // Feature test that the Safari workaround will work
      // This is *not* a browser sniff! Browsers other
      // than Safari will use the workaround if they
      // have the necessary features. This is not a problem.
      // Tested Safari 1.0, 1.2, 1.3, 2.0 and they all 
      // need the workaround and all return typeof 'object'
      // for unset global.onclick property.
      var useSafariWorkaround =
        !!(typeof global.onclick == 'object');
                                
      if (useSafariWorkaround) {
        var safariListeners = {
          click: [],
          dblclick: []
        }
      }

      LIB.addListener = function(element, eventType,
                                               handler, options) {
                                                 
        var listener = createListener(element, eventType,
                                                handler, options);

        if (useSafariWorkaround &&
            (eventType == 'click' || eventType =='dblclick') &&
            element == global) {
          var a = safariListeners[eventType];
          a[a.length] = listener;
        }
        else {
          element.addEventListener(
            listener.eventType,
            listener.wrappedHandler,
            false);
          listeners[listeners.length] = listener;
        }
      };

      var prevented = false;
      if (useSafariWorkaround) {
        // Clobbers existing DOM0 element. It is
        // trivial to build a workaround for that
        // but it shouldn't be an issue anyway.
        global.onclick = 
        global.ondblclick = 
          function(e) {
            // Safari 2.0 needs the +'' to access property
            // without a silent error
            callListeners(safariListeners[e.type + ''], e);
            var result = !prevented;
            prevented = false;
            return result;
          };
      }
      
      // event must bubble up to window
      // so no cancelBubble. It is a bad practice
      // anyway because canceling bubble foils
      // unrelated delegate listeners
      LIB.preventDefault = function(e) {
        prevented = true;
        if (LIB.isHostMethod(e, 'preventDefault')) {
         e.preventDefault();
        }
      };
    
    } // END DOM2 event model
    // BEGIN IE event model
    else if (LIB.isHostMethod(global, 'attachEvent') &&
             LIB.isHostMethod(anElement, 'attachEvent') &&
             LIB.isHostMethod(global, 'detachEvent') &&
             LIB.isHostMethod(anElement, 'detachEvent')) {
      
      LIB.addListener = function(element, eventType, 
                                               handler, options) {
        var listener = createListener(element, 'on'+eventType,
                                                handler, options);
      
        if (eventType == 'unload' && global == element) {
          unloadListeners[unloadListeners.length] = listener;
        }
        else {
          element.attachEvent(
            listener.eventType,
            listener.wrappedHandler);
          listeners[listeners.length] = listener;
        }
      };

      // IE leaks memory and want to cleanup
      // before when the document unloads
      var unloadListeners = [];

      global.attachEvent('onunload', 
        function(e) {
          // uncomment the next line to test
          // circular reference memory leak in IE
          //return;
          e = e || global.event;
          callListeners(unloadListeners, e);

          for (var i=listeners.length; i--; ) {
            listener = listeners[i];
            if (listener) {
              // break circular reference to
              // fix IE memory leak
              listener.element.detachEvent(
                listener.eventType,
                listener.wrappedHandler)
              // help garbage collection
              listeners[i] = null;
            }
          }
          global.detachEvent('onunload', arguments.callee);
          // help garbage collection
          global = null;
        });
      
      LIB.preventDefault = function(e) {
        e.returnValue = false;
      };
      
    } // END IE event model 

    // clean up for memory leaks
    anElement = null;
  }
  
})();

Comments

Have something to write? Comment on this article.

Lucian Lature February 19, 2008

Oh, you're back!...me happy ;)

kangax February 19, 2008

Pure madness. Can we automate this "bootstrapping" process?

Peter Michaux February 19, 2008

kangax,

I don't see it as madness. Accepting feature testing as prerequisite to building cross-browser widgets means a shift in thinking. That takes time. The extra code won't seem like a burden but rather a benefit as the page will work in more than just a few recent versions of a few browsers.

Yes the feature testing can be streamlined. It depends partly on how the library is written. I could have added a library function like this.

LIB.areAvailable('isHostMethod hasClass addListener querySelector')

That would return true or false if all the methods named are available to me. It saves some space.

Also, as I mentioned in the article, the library could present groups of functions if all functions in that group are supported. Or the library can be all or nothing.

The supportsCssDisplay function is particular to the widget so that probably needs to be written for each widget.

David Golightly February 20, 2008

You just blew my mind a little. The widget code is fine (see also Christian Heilmann's pass at a similar project), but your miniature cross-browser DOM library is amazing. It's got to sport one of the highest feature-to-line-of-code ratios out there. Particularly your CSS selector engine is both incredibly small and incredibly fast. Kudos!

Peter Michaux February 20, 2008

David,

Thanks. Building the selector engines is fun because it is building a tiny but real compiler. I made a couple much more full-featured versions but id, tagName and className are sufficient and keeps it small. It may not impress feature hungry folks on the Slick Speed tests with so many unsupported (and not so useful) selectors.

In general, I think the mainstream libraries are a little too big these days and they don't give the application developer the feature testing hooks needed.

Thanks for the link also. I would be interested to know what Christian Heilmann thinks of this type of testing and progressive enhancement I discuss here. Perhaps he will stop by.

David Golightly February 20, 2008

I've built my own CSS engine and find that it makes a great interview question for JavaScript developers, since it covers a little bit of every skill you need for browser development. However, my version got lured in to feature bloat by the slickspeed test (even as I point out the problems with the test); your approach is all the better for its concision. Even John Resig has brought up actually stripping out CSS3 functionality from jQuery, as people start to realize that the extra code bloat isn't worth it.

Anyways, great post! Keep up the good work.

Matt Kruse February 20, 2008

This is fantastic work and shows what is possible when a person wants to do it "right". Kudos.

But let me play devil's advocate. You have 20k (uncompressed) worth of code that is unreadable to anyone who is not a javascript expert. If this were packaged up into a reusable form with a simple API, the average web developer could probably use it. That would be great.

But as a development strategy, this kind of detailed approach is simply out of reach for most people. You cannot expect the majority of web developers to comprehend this kind of code, much less write it.

Strategies and robust code like this needs to be built into reusable library form so it can be used by the "masses" and really benefit the general web. If the average web developer doesn't have a way to implement this kind of strategy without becoming a javascript expert, he will always revert back to solutions that work but are not as robust. Every language has standard libraries and utility methods that are coded by the experts so the average developer doesn't need to know the dirty details of doing everything right. Javascript should be no exception. Not everyone can be an expert.

I hope that you can help lead the way to finding a middle ground between the robust and technically solid code of the experts who despise general-purpose libraries, and the need of average developers everywhere to have reusable code that they can actually put to use. Otherwise people who sympathize with the latter but lack the former will continue to do the job. ;)

Peter Michaux February 20, 2008

Matt,

Thanks for the complement and comment.

Uncompressed but with comments removed, the code that the application programmer writes is only 5k and doesn't deal with any particular browser bug workarounds. The application programmer's job is reduced to what really amount to about as much code as some "forward declarations". Testing CSS support is a little more involved but that is part of doing it right. The support for display='none' could have been moved into the library but that wouldn't have protected against problems like !important rules in user's stylesheet clobbering and breaking the widget.

I don't mention that the application code is 5k to argue but, even for an article where I could have coded everything inline, I did make an effort to move as much code into the library portion. I even included the library as an appendix in an attempt to stress that the application programmer wouldn't need to write that very tricky code. I agree with you that the library should be the place for the most difficult code. Even as a programmer that knows JavaScript in reasonable detail, I don't want to deal with those problems when I'm thinking about business rules and user experience.

Please don't mistake me as anti-library. It seems that even the contributors to comp.lang.javascript that abhor the current mainstream libraries do, in fact, maintain their own libraries. They openly admit they would be insane to write everything by hand every time. It could be that their manifestations of "library" are different than the mainstream libraries but there have been many good reasons presented why. The difference between developing for the general web and for behind a login are enormous and one-library-fits-all has been argued a failed approach.

You are right this is tricky code and advanced stuff but I'm not writing this for hobbyists or programmers just punching the clock in a company that is satisfied having the clock punchers. I'm writing this for library authors and professionals. When scripting browsers we have an inherently difficult task and as professionals it is our obligation to rise to that task and learn how to do it right no matter how difficult it is. It was difficult for me to learn how to write a widget like I've shown in this article; however, after doing it the first time the ideas didn't seem so difficult anymore.

I agree that the process of doing it right can be streamlined. I hope that articles like this will help convince the library authors and professional that doing it right is important and show what is involved. Then the techniques can be improved and made more accessible. This will trickle down to the masses.

John Resig February 21, 2008

"Cannot test that CSS support works until the DOM is ready."

That is incorrect - CSS is not guaranteed to be loaded when "DOMContentLoaded" fires - only the DOM is guaranteed to be loaded.

To quote David Bloom of Opera: "Opera bug 272870. HTML5 only defines the DOMContentLoaded event as firing anytime after parsing and before onload. We're considering modifying our behavior to be more like Mozilla's."

(Mozilla's current behavior is to wait until CSS is loaded.)

Peter Michaux February 21, 2008

John,

Thanks for the comment. I was uncomfortable with the DOMContentLoaded part of the code because there always seems to be a catch with trying to do anything before window.onload fires. I have reverted the example to using window.onload. We will see if this version stands up to further scrutiny.

Do you think you can put some of the feature detection techniques I discuss in this article to work in jQuery?

Matt Brown February 22, 2008

As a contributor to the Rico library, I read your post with great interest. I certainly fall into the guilty camp by often writing code that only works on the latest browsers. Could Rico do a better job at supporting more browsers? Certainly! On the other hand, I would argue that supporting the big 4 doesn't leave out 15% of web visitors, more like <1>

One area of browser compatibility that often gets overlooked is using javascript to compensate for rendering issues in specific browsers. For example, in one situation we had to render elements differently in FF on a Mac. Not a question of what was supported, simply had to do with display artifacts in the rendered result. Unfortuntely, I couldn't figure out a way to do this without testing the user agent string.

Just food for thought. I really appreciate you sharing these insights.

Matt

Matt Brown February 22, 2008

Sorry, last line of the first paragraph above should read:

On the other hand, I would argue that supporting the big 4 doesn't leave out 15% of web visitors, more like less than 1% ( e.g. these stats )

Peter Michaux February 22, 2008

Matt,

I made a back of the envelope calculation about browser support for a common subset of current versions of the main four browsers. I figured that it is likely 15% of users are outside this common set of browsers and would see a broken web page if images, CSS and JavaScript were all enabled in their older or more exotic browsers. I wasn't considering what happens when a browser has images, CSS and/or JavaScript disabled and the page depends on various combinations of those features. Even if my calculation is quite off, I don't think the 99.99% support number the library evangelists like to use is any where near the truth either.

Richard Taubo February 22, 2008

Hi,

and thanks for a lot of good information!

Seeing that the Fork JavaScript Library has not been updated for a while, and that you are using what seems to be a new library code (LIB) above, is LIB something that is going into Fork somehow or what is your stand on this?

Thanks!

Rick

Matt Kruse February 22, 2008

While thinking about this further, I had another thought similar to what Matt Brown mentioned - what about rendering issues that are not detectable via script?

For example, we know that in IE objects such as select elements and ActiveX objects "bleed thru" no matter the z-index. I'm not sure if there is any way to detect this other than inferring the behavior based on the browser being used. Typically I use conditional comments to check, but even that is risky because it doesn't guarantee that other browsers won't implement CC's, and that the problem truly exists for all versions of IE.

There are other browser-specific rendering issues that must be corrected in script as well (assuming you don't just design around the problems). Do you have any recommendations for working around these problems without resorting to browser sniffing?

Peter Michaux February 22, 2008

Rick,

The library code I used in this example was written just for this article. I'm not sure what I'm going to do with Fork. I've question its granularity and modularity since I released it. I'm still thinking on it.

Peter Michaux February 22, 2008

Matt,

If a workaround is known, and there truly is no test (or no practical test) for the problem then we can feature test that the workaround is supported and use it in all browsers that can use it. This may have a performance hit in the browsers that don't need the workaround but fortunately these types of problems are not common and the workarounds not big performance hits. If the problem happens to be in IE then the majority of users will need the workaround, so having a minority with a slight performance hit is not the end of the world. If the performance hit is very large then it is very large for the users needing the workaround and perhaps designing the problem out should be reconsidered.

If ActiveX bleed through is a problem then we already have a big clue how to test because we know ActiveX elements are the problem and few browsers have ActiveX elements. In such a case, it isn't sniffing to look for ActiveX support because that is a feature very closely related to the problem.

The select element bleed through problem in IE is tricky and I haven't meditated on that because I've designed it out of the pages where it would be an issue. We know we can use the iframe shim workaround in all browsers to stop the bleed through in IE. Now if we can find related tests that reducing the number of browsers that use that workaround we can adding those tests at any time.

David Mark February 23, 2008

Great article, Peter.

To address some of the concerns in the comments:

1. The "sniffing is faster" argument is silly. It takes time to unlock and open a door, so why not just kick it in? Never mind that you will likely break something in the process.

2. The undetectable/unfixable quirk/bug argument is also no excuse for sniffing. There isn't a quirk out there that cannot be tested and/or designed out of a system. Those who disagree should post their concerns and cases to the clj newsgroup.

3. The mini-library used in the example can hardly be considered bloated and can obviously be re-used for other widgets. Furthermore, such a framework considerably simplifies the task of writing a proper gateway. The alternative is to blunder ahead and rearrange the DOM based on misinformation (or no information at all), which is an inexcusably incompetent (and commonly used) strategy for a public Web application.

4. Yes, Opera lags in some cases in the application of linked style sheets. This is easily observed as FOUC (flash of unstyled content) and easily defeated with a script element in the head. If there is no FOUC during the progressive rendering of the page, then it follows that there is no issue with testing CSS features in the DOMContentLoaded event. And as mentioned, Opera's developers are (wisely) considering designing this issue out of their browser, regardless of the wording of the proposed HTML5 specification.

5. It is not necessary for authors of widgets to understand all of the inner workings of the functions in the example library. How many developers who resort to bloated, monolithic, browser sniffing libraries read and understand the code they are using? At least in this case, faith in the underlying logic is justified.

And BTW, your posted streamlining example (areFeatures function?) is not visible in IE7. All I see is a horizontal scrollbar. Check your rules for PRE elements.

Jasper Jones March 6, 2008

Excellent information. This is what I've been struggling with for quite a while - ever since I discovered widgets! Very thorough - can't wait to try my own take on it now. Thanks for sharing.

Alice April 13, 2008

I am glad you mentioned the cell phone as a browser to seriously consider as many people are adopting this as a primary web site and therefore widget viewing appliance. Interesting points you made here, it is helping me even though I trying some things with xhtml right now.

Tagesgeld April 24, 2008

I really hope that we immediately stop building mobile-pages for cell phones - just take a look at the iPhone: within two years there will no need for special mobile-pages at all. Just my 2 cent

Webdesigner September 19, 2008

Very interesting article. Producing those kinds of widgets and making them work across all browsers (IE6 included) can be tricky though. From a usability point of view I prefer the tabs in Google Maps. They are relatively simple to implement as well.

Jason S November 7, 2008

wow -- I've only had time to skim the article but am looking fwd to giving it a good read, and my "signal-to-noise" detectors for Internet postings are telling me thumbs up. (a rarity these days)

Thanks for pointing out what appear to be some really good best practices as well as the thought processes behind them.

Jananeekar February 11, 2010

As Im just planning to develop a widget, I came across your blog and it really guided me a way to follow it up, I will strive to take all considerations that you have mentioned, and I hope my widget works well....Thank you Peter.

Have something to write? Comment on this article.