Fixing the details Element

James Edwards
Share

The HTML5 <details> element is a very neat construct, but it also has quite a serious usability problem – what happens if you follow a hash-link which targets inside a collapsed <details> element? The answer is nothing. It’s as if the target was hidden. But we can fix that issue with a little progressively-enhanced JavaScript, and an accessible polyfill for browsers without native support.

Introducing <details>

If you’re not already familiar with the details and summary elements, here’s a quick example:

<details open="open">
  <summary>This is the summary element</summary>
  <p>
    This is the expanding content
  </p>
</details>

The <summary> element, if present, must be the first or last child. Everything else is considered to be the content. The content is collapsed by default unless the open attribute is defined. Native implementations update that attribute when the user clicks the summary to open and close it. Currently, only Chrome supports the <details> tag. The following figure shows how Chrome renders the previous example.

The details and summary element in Chrome

The details and summary element in Chrome

It’s no different than normal text, except for the small triangle, referred to as a discloure triangle. Users can open and close it by clicking the triangle, or anywhere inside the <summary> element. You can also Tab to the summary and press Enter.

Creating a Polyfill

It’s pretty straightforward to implement a basic polyfill to emulate the <details> tag. The polyfill identifies native implementations by the existence of the open property – a DOM mapping of the open attribute. In native implementations, we don’t have to manually update the open attribute, but we do still have to update its ARIA attributes, which are based on the following structure.

<details open="open">
  <summary>This is the summary element</summary>
  <div>
    <p>
      This is the expanding content
    </p>
  </div>
</details>

The inner <div> is the collapsing content. The script binds an aria-expanded attribute to that element, which switches between true and false when the element is opened and closed. The attribute is also used as a CSS selector (shown below), which visually collapses the content using display.

details > div[aria-expanded="false"]
{
  display:none;
}

Now we don’t really need a wrapping content element, but without that we’d have to set aria-expanded and display on each inner element individually – which is more work, and could be rather inconvenient if the elements have different display properties. This is especially true in IE7! For some reason, IE7 doesn’t apply the display change when the user manually opens and closes it. However, it does apply it by default (which proves it understands the selector), and the change in attribute value can be seen in the DOM. It’s as though it can apply the selector, but not un-apply it again. For that reason, we have to define a style.display change too, which makes it particularly convenient to have a content element; and since we have to do that for IE7, we end up getting IE6 support for free!

The only other significant thing to note in the polyfill is the addClickEvent abstraction, which handles the difference between browsers which fire keyboard click events, and those that don’t:

function addClickEvent(node, callback)
{
  var keydown = false;
  addEvent(node, 'keydown', function()
  {
    keydown = true;
  });
  addEvent(node, 'keyup', function(e, target)
  {
    keydown = false;
    if(e.keyCode == 13) { callback(e, target); }
  });
  addEvent(node, 'click', function(e, target)
  {
    if(!keydown) { callback(e, target); }
  });
}

For elements like links and buttons, which natively accept keyboard focus, all browsers fire the click event when you press the Enter key. But, our <summary> elements only accept the focus because we added tabindex, and here the situation varies by browser.

It’s really only the difference that’s a problem – if all browsers behaved one way or the other, things would be simple. But, since there are different behaviors we have to use a little cunning. So, we define keydown and keyup events to handle the Enter key. The events also set and clear a flag which the click event then refers to, so it can ignore duplicate keyboard events while handling mouse and touch events.

Highlighting the Hash Problem

So now we’ve got a functional polyfill, let’s link to that example again, but this time including a fragment identifier (i.e. a hash link) that points to the ID of the first element’s content:

Since the target element is inside a collapsed region, the page never jumps to that location – it stays at the top of the page while the target remains hidden. In most cases, a user wouldn’t understand what happened there. Perhaps they might scroll down, click stuff, and eventually find what they were looking for, but this is not good usability.

A worse example of the same issue arises when clicking an internal hash link – if the target is inside a collapsed region, the link will do nothing at all. Happily though, this is a case that’s easy to describe, and therefore easy to define the logic that addresses it:

  • If the hash matches the ID of an element on this page, and that element is inside (or is) a <details> element, then automatically expand the element, and any identical ancestors

Once we’ve implemented that, we’ll get much better behavior, as the details region automatically expands to expose the location target:

Fixing the Hash Problem

We can fix the hashing problem with the following recursive function.

function autostate(target, expanded, ancestor)
{
  if(typeof(ancestor) == 'undefined')
  {
    if(!(target = getAncestor(target, 'details')))
    {
      return null;
    }
    ancestor = target;
  }
  else
  {
    if(!(ancestor = getAncestor(ancestor, 'details')))
    {
      return target;
    }
  }

  statechange(ancestor.__summary, expanded);

  return autostate(target, expanded, ancestor.parentNode);
}

The function accepts a target element and the expanded=false state flag, and will identify whether the target is inside a <details> element. If so, it passes its <summary> element (saved as a local __summary property) to the statechange function, which applies the necessary changes to expand the element. Next, recur up the DOM to do the same thing with any ancestors, so that we can handle nested instances. We need to have separate arguments for the original target and subsequent ancestors, so we can return the original target at the end of all recursions, i.e. if the input target was inside a collapsed region, the same target is returned, otherwise null is returned.

We can then call autostate from click events on internal page links, as well as calling it at page load for the element matched by location.hash:

if(location.hash)
{
  autostate(document.getElementById(location.hash.substr(1)), false);
}

Originally, I wanted that to be all the function does – get the target, expand its containers, then let the browser jump to its location. But, in practice that wasn’t reliable because in order to make it work, the elements had to be expanded before the link was clicked, otherwise the browser wouldn’t jump to the target location. I tried to fix that by preempting the link action using separate mousedown, keydown, and touchstart events, so the target would already be expanded before the link is followed. Unfortunately, that was very convoluted and it still wasn’t reliable!

So, eventually I found that the best approach was to auto-scroll the browser using the window.scrollBy function, before still returning true on the link so the address bar is updated. This is where we need the target reference (or lack of it) returned by the autostate function – if it returns a target then scroll to the target’s position:

if(target = autostate(document.getElementById('hash'), false))
{
  window.scrollBy(0, target.getBoundingClientRect().top);
}

Using the getBoundingClientRect function provides the perfect data, since it tells us the position of the target element relative to the viewport (i.e. relative to the part of the document you can see inside the browser window). This means it only scrolls as far as necessary to find the target, and is why we use scrollBy instead of scrollTo. But, we don’t do that when handling the default location.hash, in order to mirror native browser behavior with ordinary hash links – when you refresh a page with a location hash, the browser doesn’t jump back to the target location, it only does that the first time the page loads.

So, to get that behavior, we musn’t auto-scroll for location targets. Instead, we must allow the native jump to happen at the appropriate time. We achieve this by deferring the script’s initialization with DOMContentLoaded (plus a backup onload for older browsers), which means that the page has already jumped to the target location, before the script collapses its containing regions in the first place.

Conclusion

I think of scripting like this as an omnifill. It’s more than just a polyfill for browsers without the latest features, as it also enhances the usability and accessibility of the features themselves, even in browsers which already support them. The download files for the examples in this article are listed below.