hostmonster-Host Unlimited Domains on 1 Account   coolhandle offering reliable webhosting since 2001
Unlimited Hosting Space - FREE Site Builder   Smart Website Solutions for Your Small Business=

Making A Complete Polyfill For The HTML5 Details Element

Making A Complete Polyfill For The HTML5 Details Element

HTML5 introduced a bunch of new tags, one of which is details. This element is a solution for a common UI component: a collapsible block. Almost every framework, including Bootstrap and jQuery UI, has its own plugin for a similar solution, but none conform to the HTML5 specification — probably because most were around long before details got specified and, therefore, represent different approaches. A standard element allows everyone to use the same markup for a particular type of content. That’s why creating a robust polyfill makes sense1.

Disclaimer: This is quite a technical article, and while I’ve tried to minimize the code snippets, the article still contains quite a few of them. So, be prepared!

Existing Solutions Are Incomplete

I’m not2 the first person3 to try to implement such a polyfill. Unfortunately, all other solutions exhibit one or another problem:

  1. No support for future content
    Support for future content is extremely valuable for single-page applications. Without it, you would have to invoke the initialization function every time you add content to the page. Basically, a developer wants to be able to drop details into the DOM and be done with it, and not have to fiddle with JavaScript to get it going.
  2. Not a true polyfill for the open attribute
    According to the specification4, details comes with the open attribute, which is used to toggle the visibility of the contents of details.
  3. The toggle event is missing
    This event is a notification that a details element has changed its open state. Ideally, it should be a vanilla DOM event.

In this article we’ll use better-dom5 to make things simpler. The main reason is the live extensions6 feature, which solves the problem of invoking the initialization function for dynamic content. (For more information, read my detailed article about live extensions7.) Additionally, better-dom outfits live extensions with a set of tools that do not (yet) exist in vanilla DOM but that come in handy when implementing a polyfill like this one.

1-details-element-in-Safari-8-opt8
The details element in Safari 8 (18149)

Check out the live demo10.

Let’s take a closer look at all of the hurdles we have to overcome to make details available in browsers that don’t support it.

Future Content Support

To start, we need to declare a live extension for the "details" selector. What if the browser already supports the element natively? Then we’ll need to add some feature detection. This is easy with the optional second argument condition, which prevents the logic from executing if its value is equal to false:

// Invoke extension only if there is no native support
var open = DOM.create("details").get("open");

DOM.extend("details", typeof open !== "boolean", {
  constructor: function() {
    console.log("initialize details…");
  }
});

As you can see, we are trying to detect native support by checking for the open property, which obviously only exists in browsers that recognize details.

What sets DOM.extend11 apart from a simple call like document.querySelectorAll is that the constructor function executes for future content, too. And, yes, it works with any library for manipulating the DOM:

// You can use better-dom…
DOM.find("body").append(
  "detailssummaryTITLE/summarypTEXT/p/details");
// = logs "initialize details…"

// or any other DOM library, like jQuery…
$("body").append(
  "detailssummaryTITLE/summarypTEXT/p/details");
// = logs "initialize details…"

// or even vanilla DOM.
document.body.insertAdjacentElement("beforeend",
  "detailssummaryTITLE/summarypTEXT/p/details");
// = logs "initialize details…"

In the following sections, we’ll replace the console.log call with a real implementation.

Implementation Of summary Behavior

The details element may take summary as a child element.

The first summary element child of details, if one is present, represents an overview of details. If no child summary element is present, then the user agent should provide its own legend (for example, “Details”).

Let’s add mouse support. A click on the summary element should toggle the open attribute on the parent details element. This is what it looks like using better-dom:

DOM.extend("details", typeof open !== "boolean", {
  constructor: function() {
    this
      .children("summary:first-child")
      .forEach(this.doInitSummary);
  },
  doInitSummary: function(summary) {
    summary.on("click", this.doToggleOpen);
  },
  doToggleOpen: function() {
    // We’ll cover the open property value later.
    this.set("open", !this.get("open"));
  }
});

The children method returns a JavaScript array of elements (not an array-like object as in vanilla DOM). Therefore, if no summary is found, then the doInitSummary function is not executed. Also, doInitSummary and doToggleOpen are private functions12, they always are invoked for the current element. So, we can pass this.doInitSummary to Array#forEach without extra closures, and everything will execute correctly there.

Having keyboard support in addition to mouse support is good as well. But first, let’s make summary a focusable element. A typical solution is to set the tabindex attribute to 0:

doInitSummary: function(summary) {
  // Makes summary focusable
  summary.set("tabindex", 0);
  …
}

Now, the user hitting the space bar or the “Enter” key should toggle the state of details. In better-dom, there is no direct access to the event object. Instead, we need to declare which properties to grab using an extra array argument:

doInitSummary: function(summary) {
  …
  summary.on("keydown", ["which"], this.onKeyDown);
}

Note that we can reuse the existing doToggleOpen function; for a keydown event, it just makes an extra check on the first argument. For the click event handler, its value is always equal to undefined, and the result will be this:

doInitSummary: function(summary) {
  summary
    .set("tabindex", 0)
    .on("click", this.doToggleOpen)
    .on("keydown", ["which"], this.doToggleOpen);
},
doToggleOpen: function(key) {
  if (!key || key === 13 || key === 32) {
    this.set("open", !this.get("open"));
    // Cancel form submission on the ENTER key.
    return false;
  }
}

Now we have a mouse- and keyboard-accessible details element.

summary Element Edge Cases

The summary element introduces several edge cases that we should take into consideration:

1. When summary Is a Child But Not the First Child

2-summary-element-is-not-the-first-child-opt13
What the Chrome browser outputs when the summary element is not the first child. (18149)

Browser vendors have tried to fix such invalid markup by moving summary to the position of the first child visually, even when the element is not in that position in the flow of the DOM. I was confused by such behavior, so I asked the W3C for clarification15. The W3C confirmed that summary must be the first child of details. If you check the markup in the screenshot above on Nu Markup Checker16, it will fail with the following error message:

Error: Element summary not allowed as child of element details in this context. […] Contexts in which element summary may be used: As the first child of a details element.

My approach is to move the summary element to the position of the first child. In other words, the polyfill fixes the invalid markup for you:

doInitSummary: function(summary) {
  // Make sure that summary is the first child
  if (this.child(0) !== summary) {
    this.prepend(summary);
  }
  …
}

2. When the summary Element Is Not Present

3-summary-element-does-not-exist-opt17
What the Chrome browser outputs when the summary element is not present (18149)

As you can see in the screenshot above, browser vendors insert “Details” as a legend into summary in this case. The markup stays untouched. Unfortunately, we can’t achieve the same without accessing the shadow DOM19, which unfortunately has weak support20 at present. Still, we can set up summary manually to comply with standards:

constructor: function() {
  …
  var summaries = this.children("summary");
  // If no child summary element is present, then the
  // user agent should provide its own legend (e.g. "Details").
  this.doInitSummary(
    summaries[0] || DOM.create("summary`Details`"));
}

Support For open Property

If you try the code below in browsers that support details natively and in others that don’t, you’ll get different results:

details.open = true;
// details changes state in Chrome and Safari
details.open = false;
// details state changes back in Chrome and Safari

In Chrome and Safari, changing the value of open triggers the addition or removal of the attribute. Other browsers do not respond to this because they do not support the open property on the details element.

Properties are different from simple values. They have a pair of getter and setter functions that are invoked every time you read or assign a new value to the field. And JavaScript has had an API to declare properties since version 1.5.

The good news is that one old browser we are going to use with our polyfill, Internet Explorer (IE) 8, has partial support for the Object.defineProperty function. The limitation is that the function works only on DOM elements. But that is exactly what we need, right?

There is a problem, though. If you try to set an attribute with the same name in the setter function in IE 8, then the browser will stack with infinite recursion and crashes. In old versions of IE, changing an attribute will trigger the change of an appropriate property and vice versa:

Object.defineProperty(element, "foo", {
  …
  set: function(value) {
    // The line below triggers infinite recursion in IE 8.
    this.setAttribute("foo", value);
  }
});

So you can’t modify the property without changing an attribute there. This limitation has prevented developers from using the Object.defineProperty for quite a long time.

The good news is that I’ve found a solution.

Fix For Infinite Recursion In IE 8

Before describing the solution, I’d like to give some background on one feature of the HTML and CSS parser in browsers. In case you weren’t aware, these parsers are case-insensitive. For example, the rules below will produce the same result (i.e. a base red for the text on the page):

body { color: red; }
/* The rule below will produce the same result. */
BODY { color: red; }

The same goes for attributes:

el.setAttribute("foo", "1");
el.setAttribute("FOO", "2");
el.getAttribute("foo"); // = "2"
el.getAttribute("FOO"); // = "2"

Moreover, you can’t have uppercased and lowercased attributes with the same name. But you can have both on a JavaScript object, because JavaScript is case-sensitive:

var obj = {foo: "1", FOO: "2"};
obj.foo; // = "1"
obj.FOO; // = "2"

Some time ago, I found that IE 8 supports the deprecated legacy argument lFlags21 for attribute methods, which allows you to change attributes in a case-sensitive manner:

  • lFlags [in, optional]
    • Type: Integer
    • Integer that specifies whether to use a case-sensitive search to locate the attribute.

Remember that the infinite recursion happens in IE 8 because the browser is trying to update the attribute with the same name and therefore triggers the setter function over and over again. What if we use the lFlags argument to get and set the uppercased attribute value:

// Defining the "foo" property but using the "FOO" attribute
Object.defineProperty(element, "foo", {
  get: function() {
	return this.getAttribute("FOO", 1);
  },
  set: function(value) {
    // No infinite recursion!
    this.setAttribute("FOO", value, 1);
  }
});

As you might expect, IE 8 updates the uppercased field FOO on the JavaScript object, and the setter function does not trigger a recursion. Moreover, the uppercased attributes work with CSS too — as we stated in the beginning, that parser is case-insensitive.

Polyfill For The open Attribute

Now we can define an open property that works in every browser:

var attrName = document.addEventListener ? "open" : "OPEN";

Object.defineProperty(details, "open", {
  get: function() {
    var attrValue = this.getAttribute(attrName, 1);
    attrValue = String(attrValue).toLowerCase();
    // Handle boolean attribute value
    return attrValue === "" || attrValue === "open";
  }
  set: function(value) {
    if (this.open !== value) {
      console.log("firing toggle event");
    }

    if (value) {
      this.setAttribute(attrName, "", 1);
    } else {
      this.removeAttribute(attrName, 1);
    }
  }
});

Check how it works:

details.open = true;
// = logs "firing toggle event"
details.hasAttribute("open"); // = true
details.open = false;
// = logs "firing toggle event"
details.hasAttribute("open"); // = false

Excellent! Now let’s do similar calls, but this time using *Attribute methods:

details.setAttribute("open", "");
// = silence, but fires toggle event in Chrome and Safari
details.removeAttribute("open");
// = silence, but fires toggle event in Chrome and Safari

The reason for such behavior is that the relationship between the open property and the attribute should be bidirectional. Every time the attribute is modified, the open property should reflect the change, and vice versa.

The simplest cross-browser solution I’ve found for this issue is to override the attribute methods on the target element and invoke the setters manually. This avoids bugs and the performance penalty of legacy propertychange22 and DOMAttrModified23 events. Modern browsers support MutationObservers24, but that doesn’t cover our browser scope.

Final Implementation

Obviously, walking through all of the steps above when defining a new attribute for a DOM element wouldn’t make sense. We need a utility function for that which hides cross-browser quirks and complexity. I’ve added such a function, named defineAttribute25, in better-dom.

The first argument is the name of the property or attribute, and the second is the get and set object. The getter function takes the attribute’s value as the first argument. The setter function accepts the property’s value, and the returned statement is used to update the attribute. Such a syntax allows us to hide the trick for IE 8 where an uppercased attribute name is used behind the scenes:

constructor: function() {
  …
  this.defineAttribute("open", {
    get: this.doGetOpen,
    set: this.doSetOpen
  });
},
doGetOpen: function(attrValue) {
  attrValue = String(attrValue).toLowerCase();
  return attrValue === "" || attrValue === "open";
},
doSetOpen: function(propValue) {
  if (this.get("open") !== propValue) {
    this.fire("toggle");
  }
  // Adding or removing boolean attribute "open"
  return propValue ? "" : null;
}

Having a true polyfill for the open attribute simplifies our manipulation of the details element’s state. Again, this API is framework-agnostic:

// You can use better-dom…
DOM.find("details").set("open", false);

// or any other DOM library, like jQuery…
$("details").prop("open", true);

// or even vanilla DOM.
document.querySelector("details").open = false;

Notes On Styling

The CSS part of the polyfill is simpler. It has some basic style rules:

summary:first-child ~ * {
  display: none;
}

details[open]  * {
  display: block;
}

/*  Hide native indicator and use pseudo-element instead */
summary::-webkit-details-marker {
  display: none;
}

I didn’t want to introduce any extra elements in the markup, so obvious choice is to style the ::before pseudo-element. This pseudo-element is used to indicate the current state of details (according to whether it is open or not). But IE 8 has some quirks, as usual — namely, with updating the pseudo-element state. I got it to work properly only by changing the content property’s value itself:

details:before {
  content: '25BA';
  …
}

details[open]:before {
  content: '25BC';
}

For other browsers, the zero-border trick will draw a font-independent CSS triangle. With a double-colon syntax for the ::before pseudo-element, we can apply rules to IE 9 and above:

details::before {
  content: '';
  width: 0;
  height: 0;
  border: solid transparent;
  border-left-color: inherit;
  border-width: 0.25em 0.5em;
  …
  transform: rotate(0deg) scale(1.5);
}

details[open]::before {
  content: '';
  transform: rotate(90deg) scale(1.5);
}

The final enhancement is a small transition on the triangle. Unfortunately, Safari does not apply it for some reason (perhaps a bug), but it degrades well by ignoring the transition completely:

details::before {
  …
  transition: transform 0.15s ease-out;
}
4-details-element-animation26
A sample animation for the toggle triangle

You can find the full source code on Github27.

Putting It All Together

Some time ago, I started using transpilers in my projects, and they are great. Transpilers enhance source files. You can even code in a completely different language, like CoffeeScript instead of JavaScript or LESS instead of CSS etc. However, my intention in using them is to decrease unnecessary noise in the source code and to learn new features in the near future. That’s why transpilers do not go against any standards in my projects — I’m just using some extra ECMAScript 6 (ES6) stuff and CSS post-processors (Autoprefixer28 being the main one).

Also, to speak about bundling, I quickly found that distributing *.css files along with *.js is slightly annoying. In searching for a solution, I found HTML Imports29, which aims to solve this kind of problem in the future. At present, the feature has relatively weak browser support30. And, frankly, bundling all of that stuff into a single HTML file is not ideal.

So, I built my own approach for bundling: better-dom has a function, DOM.importStyles31, that allows you to import CSS rules on a web page. This function has been in the library since the beginning because DOM.extend uses it internally. Since I use better-dom and transpilers in my code anyway, I created a simple gulp task:

gulp.task("compile", ["lint"], function() {
  var jsFilter = filter("*.js");
  var cssFilter = filter("*.css");

  return gulp.src(["src/*.js", "src/*.css"])
    .pipe(cssFilter)
    .pipe(postcss([autoprefixer, csswring, …]))
     // need to escape some symbols
    .pipe(replace(/\|"/g, "\$"))
     // and convert CSS rules into JavaScript function calls
    .pipe(replace(/([^{]+){([^}]+)}/g,
      "DOM.importStyles("$1", "$2");n"))
    .pipe(cssFilter.restore())
    .pipe(jsFilter)
    .pipe(es6transpiler())
    .pipe(jsFilter.restore())
    .pipe(concat(pkg.name + ".js"))
    .pipe(gulp.dest("build/"));
});

To keep it simple, I didn’t put in any optional steps or dependency declarations (see the full source code32). In general, the compilation task contains the following steps:

  1. Apply Autoprefixer to the CSS.
  2. Optimize the CSS, and transform it into the sequence of DOM.importStyles calls.
  3. Apply ES6 transpilers to JavaScript.
  4. Concatenate both outputs to a *.js file.

And it works! I have transpilers that make my code clearer, and the only output is a single JavaScript file. Another advantage is that, when JavaScript is disabled, those style rules are completely ignored. For a polyfill like this, such behavior is desirable.

Closing Thoughts

As you can see, developing a polyfill is not the easiest challenge. On the other hand, the solution can be used for a relatively long time: standards do not change often and have been discussed at length behind the scenes. Also everyone is using the same language and is connecting with the same APIs which is a great thing.

With the common logic moved into utility functions, the source code is not very complex33. This means that, at present, we really lack advanced tools to make robust polyfills that work close to native implementations (or better!). And I don’t see good libraries for this yet, unfortunately.

Libraries such as jQuery, Prototype and MooTools are all about providing extra sugar for working with the DOM. While sugar is great, we also need more utility functions to build more robust and unobtrusive polyfills. Without them, we might end up with a ton of plugins that are hard to integrate in our projects. May be it’s time to move into this direction?

Another technique that has arisen recently is Web Components34. I’m really excited by tools like the shadow DOM, but I’m not sure if custom elements35 are the future of web development. Moreover, custom elements can introduce new problems if everyone starts creating their own custom tags for common uses. My point is that we need to learn (and try to improve) the standards first before introducing a new HTML element. Fortunately, I’m not alone in this; Jeremy Keith, for one, shares a similar view36.

Don’t get me wrong. Custom elements are a nice feature, and they definitely have use cases in some areas. I look forward to them being implemented in all browsers. I’m just not sure if they’re a silver bullet for all of our problems.

To reiterate, I’d encourage creating more robust and unobtrusive polyfills. And we need to build more advanced tools to make that happen more easily. The example with details shows that achieving such a goal today is possible. And I believe this direction is future-proof and the one we need to move in.

(al)

Footnotes

  1. 1 http://caniuse.com/#feat=details
  2. 2 https://github.com/mathiasbynens/jquery-details
  3. 3 https://github.com/manuelbieh/Details-Polyfill
  4. 4 http://www.w3.org/html/wg/drafts/html/master/interactive-elements.html#the-details-element
  5. 5 https://github.com/chemerisuk/better-dom
  6. 6 https://github.com/chemerisuk/better-dom/wiki/Live-extensions
  7. 7 http://www.smashingmagazine.com/2014/02/05/introducing-live-extensions-better-dom-javascript/
  8. 8 http://www.smashingmagazine.com/wp-content/uploads/2014/11/1-details-element-in-Safari-8-large-opt.jpg
  9. 9
  10. 10 http://chemerisuk.github.io/better-details-polyfill/
  11. 11 http://chemerisuk.github.io/better-dom/DOM.html#extend
  12. 12 https://github.com/chemerisuk/better-dom/wiki/Live-extensions#public-members-and-private-functions
  13. 13 http://www.smashingmagazine.com/wp-content/uploads/2014/11/2-summary-element-is-not-the-first-child-large-opt.jpg
  14. 14
  15. 15 http://lists.w3.org/Archives/Public/public-html/2014Nov/0043.html
  16. 16 http://validator.w3.org/nu/
  17. 17 http://www.smashingmagazine.com/wp-content/uploads/2014/11/3-summary-element-does-not-exist-large-opt.jpg
  18. 18
  19. 19 http://www.w3.org/TR/shadow-dom/
  20. 20 http://caniuse.com/#feat=shadowdom
  21. 21 http://msdn.microsoft.com/en-us/library/ie/ms536739(v=vs.85).aspx
  22. 22 http://msdn.microsoft.com/en-us/library/ie/ms536956(v=vs.85).aspx
  23. 23 https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Mutation_events
  24. 24 https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
  25. 25 http://chemerisuk.github.io/better-dom/$Element.html#defineAttribute
  26. 26 http://www.smashingmagazine.com/wp-content/uploads/2014/11/4-details-element-animation.gif
  27. 27 https://github.com/chemerisuk/better-details-polyfill/blob/master/src/better-details-polyfill.css
  28. 28 https://github.com/postcss/autoprefixer
  29. 29 http://www.html5rocks.com/en/tutorials/webcomponents/imports/
  30. 30 http://caniuse.com/#feat=imports
  31. 31 http://chemerisuk.github.io/better-dom/DOM.html#importStyles
  32. 32 https://github.com/chemerisuk/better-dom-boilerplate/blob/master/gulpfile.js#L34
  33. 33 https://github.com/chemerisuk/better-details-polyfill/blob/master/src/better-details-polyfill.js
  34. 34 http://webcomponents.org
  35. 35 http://w3c.github.io/webcomponents/spec/custom/
  36. 36 https://adactio.com/journal/7431

↑ Back to topShare on Twitter

Article source: http://www.smashingmagazine.com/2014/11/28/complete-polyfill-html5-details-element/

Tags:


Submit a Review




If you want a picture to show with your review, go get a Gravatar.

1&1 has shared hosting and dedicated hosting solutions for every budget and free domains with all hosting packages!  StartLogic - Affordable hosting: Free setup/domain, unlimited emails, PHP, mySQL, CGI, FrontPage. As low as $3.95/month
Cloud ecommerce platform delivers more traffic, higher conversion and unmatched performance

© Copyright 2008 Tyconia International, Inc. All Rights Reserved.