Menu Search
Jump to the content X X
Smashing Conf New York

We use ad-blockers as well, you know. We gotta keep those servers running though. Did you know that we publish useful books and run friendly conferences — crafted for pros like yourself? E.g. upcoming SmashingConf Barcelona, dedicated to smart front-end techniques and design patterns.

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 Link

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. 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-dom4 to make things simpler. The main reason is the live extensions5 feature, which solves the problem of invoking the initialization function for dynamic content. (For more information, read my detailed article about live extensions6.) 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-opt7
The details element in Safari 8 (View large version17138)

Check out the live demo9.

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 Link

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.extend10 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 Link

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 functions11, 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 Link

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

1. When <summary> Is a Child But Not the First Child Link

2-summary-element-is-not-the-first-child-opt12

What the Chrome browser outputs when the summary element is not the first child. (View large version17138)

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 clarification14. The W3C confirmed that <summary> must be the first child of <details>. If you check the markup in the screenshot above on Nu Markup Checker15, 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 Link

3-summary-element-does-not-exist-opt16

What the Chrome browser outputs when the summary element is not present (View large version17138)

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 DOM18, which unfortunately has weak support19 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 Link

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 Link

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 lFlags20 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 Link

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 propertychange21 and DOMAttrModified22 events. Modern browsers support MutationObservers23, but that doesn’t cover our browser scope.

Final Implementation Link

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 defineAttribute24, 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 Link

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-animation25

A sample animation for the toggle triangle

Putting It All Together Link

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 (Autoprefixer26 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 Imports27, which aims to solve this kind of problem in the future. At present, the feature has relatively weak browser support28. 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.importStyles29, 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 code30). 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 Link

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 Components31. I’m really excited by tools like the shadow DOM, but I’m not sure if custom elements32 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 view33.

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 Link

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

Hold on, Tiger! Thank you for reading the article. Did you know that we also publish printed books and run friendly conferences – crafted for pros like you? Like SmashingConf Barcelona, on October 25–26, with smart design patterns and front-end techniques.

↑ Back to top Tweet itShare on Facebook

Advertisement

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 projects that could accelerate publishing new ideas via the Internet.

  1. 1

    Federico Brigante

    November 28, 2014 4:32 pm

    Correction: CSS parsers are NOT case insensitive with the exception of tag selectors (body, div, a, …), not even in IE8
    Test: http://codepen.io/anon/pen/qEdzde

    0
  2. 3

    You never defined an “onKeyDown” method. Therefore, this example won’t work for me in Firefox.

    0
  3. 5

    I did a quick check with VoiceOver on Safari to test the accessibility. This may be a limitation of the Details/Summary design specification, but there is no indication that the details has expanded. The simplest approach is to move focus into the expanded area, but I don’t know if this is the intended behavior of the details tag.
    Another option would be to include the aria-expanded and aria-controls attributes to pass this meta information to the screen reader.
    Kudos for considering not only the enter key, but also the space bar.

    2
    • 6

      Hi Ted, Safari has native support for the `details` element. I add appropriate aria-* attributes only if browser does not understand `details`.

      -1
      • 7

        Thank you for the reply. I was wondering about the native behavior. I’m glad you considered every angle.

        1
      • 8

        Nice work.

        Safari (and Chrome) have native support for ‘details’. That support includes the visual rendering and keyboard interaction, but does not include exposing the necessary info to the accessibility API (yet). See https://bugs.webkit.org/show_bug.cgi?id=131111

        I’d include @role and @aria-expanded even for browsers that natively ‘support’ the element, at least for the time being, thereby making it accessible to VoiceOver users at least.

        I’d also recommend adding @aria-controls on the ‘summary’ element and reference the ‘details’ element’s @id.

        Cheers.

        1
        • 9

          Thanks for the useful information Jason. In such case it does make sense to add aria-* attributes manually. I’ll include this improvement into the next release of the polyfill.

          0
  4. 10

    I Just Arrived Here From Google…and find this interesting thing..i thing i will try to use it on my website-
    Most Useful Tricks

    -6
  5. 11

    This codes are very helpful to us to rectify the issues in the Designing.

    -2
  6. 12

    Hi,
    You have formatted this article amazingly and with great information. Will try using it. Thank You!

    -2
  7. 13

    But how..where…why..when

    0
  8. 14

    This is really impressive work, and I would love to add this to the polyfill service (cdn.polyfill.io). But it’s a shame that it has a dependency on better-dom, especially as you’re rightly in favour of unobtrusive polyfills. Requiring a 2000-line library to make an 86-line polyfill work isn’t very scalable unless you’re using better-dom anyway, so that reduces the usefulness of the polyfill to most people, doesn’t it?

    How easy would it be to remove the dependency, I wonder.

    0
    • 15

      Hi Andrew, sure it’s possible to make it work without better-dom, but it’s quite complicated in this way. Live extension feature requires tricky implementation as well as attribute polyfill. I noted in the article that we still have lack of tools in vanilla DOM, to be able to make a polyfill implementations as simple as with having better-dom library as a dependency, and I believe in it. But first, we need to understand what kind of tools are useful. This is one of the better-dom library goals.

      -1
  9. 16

    For some weird reason whenever I google “Vanilla DOM” all I get are articles on BDSM.

    1
  10. 18

    Thanks , These code are very useful

    -1
  11. 19

    Vishal Kapoor

    February 4, 2015 7:50 am

    Thanks , these codes are very helpful to work .

    -1
  12. 20

    Nice thanks …..

    -1

↑ Back to top