Making A Complete Polyfill For The HTML5 Details Element

About The Author

Maksim is a freelance full-stack Web developer who lives in Minsk, Belarus. In his spare time, he likes to learn Web standards and to contribute to open source … More about Maksim ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

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. This is quite a technical article, and while Maksim Chemerisuk will try to minimize the code snippets, this article still contains quite a few of them. So, be prepared!

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 sense.

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 not the first person 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. 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-dom to make things simpler. The main reason is the live extensions feature, which solves the problem of invoking the initialization function for dynamic content. (For more information, read my detailed article about live extensions.) 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-opt
The details element in Safari 8

Check out the live demo.

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.extend 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(
  "<details><summary>TITLE</summary><p>TEXT</p></details>");
// => logs "initialize <details>…"

// or any other DOM library, like jQuery…
$("body").append(
  "<details><summary>TITLE</summary><p>TEXT</p></details>");
// => logs "initialize <details>…"

// or even vanilla DOM.
document.body.insertAdjacentElement("beforeend",
  "<details><summary>TITLE</summary><p>TEXT</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 functions, 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-opt
What the Chrome browser outputs when the summary element is not the first child.

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 clarification. The W3C confirmed that <summary> must be the first child of <details>. If you check the markup in the screenshot above on Nu Markup Checker, 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-opt
What the Chrome browser outputs when the summary element is not present

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 DOM, which unfortunately has weak support 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 lFlags 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 propertychange and DOMAttrModified events. Modern browsers support MutationObservers, 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 defineAttribute, 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-animation
A sample animation for the toggle triangle

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 (Autoprefixer 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 Imports, which aims to solve this kind of problem in the future. At present, the feature has relatively weak browser support. 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.importStyles, 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 code). 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 complex. 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 Components. I’m really excited by tools like the shadow DOM, but I’m not sure if custom elements 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 view.

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.

Further Reading

Smashing Editorial (al, mrn)