Expand/Collapse (based on Details/Summary)

This pattern is nearly identical in intent to the plain Expand/Collapse pattern, except that it adds the HTML 5 semantics of the summary and details elements. The summary element is the button that controls the expand/collapse action. The details element contains the content that is hidden or shown as a result of activating the summary element.

In principle, the summary and details elements could work on their own, without any need for JavaScript or a custom ARIA pattern, but in practice, not all browsers support this feature, so JavaScript and ARIA are required for cross-browser compatibility.

Support Note: Works in every screen reader and browser combo except NVDA Firefox (it reads the initial state collapsed/expanded but does not announce the state change).



I Have a Dream (excerpt), by Dr. Martin Luther King

And so even though we face the difficulties of today and tomorrow, I still have a dream. It is a dream deeply rooted in the American dream.

I have a dream that one day this nation will rise up and live out the true meaning of its creed: "We hold these truths to be self-evident, that all men are created equal.

I have a dream that one day on the red hills of Georgia, the sons of former slaves and the sons of former slave owners will be able to sit down together at the table of brotherhood.

I have a dream that one day even the state of Mississippi, a state sweltering with the heat of injustice, sweltering with the heat of oppression, will be transformed into an oasis of freedom and justice.

I have a dream that my four little children will one day live in a nation where they will not be judged by the color of their skin but by the content of their character.

I have a dream today!

I have a dream that one day, down in Alabama, with its vicious racists, with its governor having his lips dripping with the words of "interposition" and "nullification" — one day right there in Alabama little black boys and black girls will be able to join hands with little white boys and white girls as sisters and brothers.

I have a dream today!

I have a dream that one day every valley shall be exalted, and every hill and mountain shall be made low, the rough places will be made plain, and the crooked places will be made straight; and the glory of the Lord shall be revealed and all flesh shall see it together.

Initial HTML Markup

<details class="deque-expander" id="dream">
   
  <summary class="deque-expander-summary">
  	<span class="toggle-indicator"></span>
    I Have a Dream (excerpt), by Dr. Martin Luther King
  </summary>

  <div class="deque-expander-content">
    <p>And so even though we face the difficulties of today and tomorrow, 
    I still have a dream. It is a dream deeply rooted in the American dream.</p>

    <p>I have a dream that one day this nation will rise up and live out the true 
    meaning of its creed: "We hold these truths to be self-evident, that all men 
    are created equal.</p>

    <p>I have a dream that one day on the red hills of Georgia, the sons of former 
    slaves and the sons of former slave owners will be able to sit down together 
    at the table of brotherhood.</p>

    <p>I have a dream that one day even the state of Mississippi, a state 
    sweltering with the heat of injustice, sweltering with the heat of oppression, 
    will be transformed into an oasis of freedom and justice.</p>

    <p>I have a dream that my four little children will one day live in a nation 
    where they will not be judged by the color of their skin but by the content 
    of their character.</p>

    <p>I have a dream today!</p>

    <p>I have a dream that one day, down in Alabama, with its vicious racists, 
    with its governor having his lips dripping with the words of &quot;interposition&quot; 
    and &quot;nullification&quot; &mdash; one day right there in Alabama little black boys and 
    black girls will be able to join hands with little white boys and white 
    girls as sisters and brothers.</p>

    <p>I have a dream today!</p>

    <p>I have a dream that one day every valley shall be exalted, and every hill 
    and mountain shall be made low, the rough places will be made plain, and the 
    crooked places will be made straight; and the glory of the Lord shall be 
    revealed and all flesh shall see it together.</p>
  </div>
</details>

JavaScript

Required: The complete JavaScript file (for all patterns in the library): deque-patterns.min.js

Key parts of the JavaScript file

Note: The code below is functional only in the context of the complete JavaScript file.

In the @expander section:


	function createExpander(container) {
	  var containerTag = container.tagName;
	  if (containerTag == 'DETAILS') {
	    if ((0, _isDetailsSupported.isDetailsSupported)() && !(0, _isIOS2.default)()) {
	      return html5Version(container);
	    } else {
	      return html4Version(container);
	    }
	  } else {
	    return html4Version(container);
	  }
	}
	
	function html5Version(container) {
	  var summary = container.querySelector('.deque-expander-summary');
	  summary.setAttribute('tabindex', '0');
	  summary.setAttribute('aria-expanded', 'false');
	
	  container.classList.add('deque-expander');
	  if (container.hasAttribute('open')) {
	    summary.setAttribute('aria-expanded', 'true');
	    container.setAttribute('open');
	  } else {
	    summary.setAttribute('aria-expanded', 'false');
	    container.removeAttribute('open');
	  }
	
	  // it would seem that browsers treat the <summary>
	  // element as if it were a button, i.e. automagically
	  // treat space and enter as 'click' events.
	  summary.setAttribute('role', 'button');
	  summary.setAttribute('aria-expanded', 'false');
	
	  function setOpenStatus() {
	    if (container.hasAttribute('open')) {
	      summary.setAttribute('aria-expanded', 'true');
	      //container.setAttribute('open');
	    } else {
	      summary.setAttribute('aria-expanded', 'false');
	      // container.removeAttribute('open');
	    }
	  }
	
	  summary.addEventListener('click', function () {
	    setTimeout(setOpenStatus);
	  });
	}
	
	function html4Version(container) {
	  var containerTag = container.tagName;
	  var summary = container.querySelector('.deque-expander-summary');
	
	  if (containerTag == 'DETAILS') {
	    /* convert summary element to div */
	    var newSummary = document.createElement('div');
	    var summaryNodes = [],
	        summaryValues = []; //collects the names and values of the summary attributes into two arrays
	    for (var att, i = 0, atts = summary.attributes, n = atts.length; i < n; i++) {
	      att = atts[i];
	      summaryNodes.push(att.nodeName);
	      summaryValues.push(att.nodeValue);
	    }
	    for (var x = 0; x < summaryNodes.length; x++) {
	      newSummary.setAttribute(summaryNodes[x], summaryValues[x]); //puts the summary attributes onto the newly created div
	    }
	    newSummary.classList.add('deque-expander-summary');
	    newSummary.innerHTML = summary.innerHTML;
	    summary.parentNode.replaceChild(newSummary, summary);
	
	    /* convert details element to div */
	    var newContainer = document.createElement('div');
	    var detailsNodes = [],
	        detailsValues = []; //collects the names and values of the details attributes into two arrays
	    for (var attContainer, j = 0, attsContainer = container.attributes, m = attsContainer.length; j < m; j++) {
	      attContainer = attsContainer[j];
	      detailsNodes.push(attContainer.nodeName);
	      detailsValues.push(attContainer.nodeValue);
	    }
	    for (var y = 0; y < detailsNodes.length; y++) {
	      newContainer.setAttribute(detailsNodes[y], detailsValues[y]); //puts the details attributes onto the newly created div
	    }
	    newContainer.classList.add('deque-expander');
	    newContainer.innerHTML = container.innerHTML;
	    container.parentNode.replaceChild(newContainer, container);
	    container = newContainer;
	    summary = container.querySelector('.deque-expander-summary');
	  }
	  summary.setAttribute('tabindex', '0');
	  summary.setAttribute('aria-expanded', 'false');
	  summary.setAttribute('role', 'button');
	
	  var content = container.querySelector('.deque-expander-content');
	  content.classList.add('deque-hidden');
	
	  function toggle(e) {
	    e.stopPropagation();
	    e.preventDefault();
	
	    content.classList.toggle('deque-hidden');
	    if (content.classList.contains('deque-hidden')) {
	      summary.setAttribute('aria-expanded', 'false');
	    } else {
	      summary.setAttribute('aria-expanded', 'true');
	    }
	  }
	
	  (0, _keyboardUtils.onElementEnter)(summary, toggle);
	  (0, _keyboardUtils.onElementSpace)(summary, toggle);
	  summary.addEventListener('click', toggle);
	}
	
	function activateAllExpanders() {
	  var expanders = document.querySelectorAll('.deque-expander');
	  for (var i = 0; i < expanders.length; i++) {
	    createExpander(expanders[i]);
	  }
	}
	
	activateAllExpanders();
	

In the @expander-isDetailsSupported section:


	function isDetailsSupported() {
	  var el = document.createElement('details');
	  var diff;
	
	  // return early if possible; thanks @aFarkas!
	  if (!('open' in el)) {
	    return false;
	  }
	
	  document.body.appendChild(el);
	  el.innerHTML = '<summary>a</summary>b';
	  diff = el.offsetHeight;
	  el.open = true;
	  diff = diff != el.offsetHeight;
	
	  document.body.removeChild(el);
	
	  return diff;
	}
	

In the @expander-isIOS section:


	exports.default = function () {
	  return (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
	  );
	};
	

In the @keyboardUtils section:


	var KEYS = exports.KEYS = {
	  BACKSPACE: 8,
	  TAB: 9,
	  ENTER: 13,
	  SHIFT: 16,
	  CTRL: 17,
	  ALT: 18,
	  ESCAPE: 27,
	  SPACE: 32,
	  LEFT: 37,
	  RIGHT: 39,
	  UP: 38,
	  DOWN: 40,
	  F10: 121,
	  HOME: 36,
	  END: 35,
	  PAGE_UP: 33,
	  PAGE_DOWN: 34
	};
	
	function bindElementToEventValue(element, eventName, testValue, handler) {
	  function localHandler(e) {
	    if (e.which === testValue) {
	      handler(e);
	    }
	  }
	
	  element.addEventListener(eventName, localHandler);
	  return function () {
	    element.removeEventListener(eventName, localHandler);
	  };
	}
	
	function bindElementToKeypressValue(element, testValue, handler) {
	  return bindElementToEventValue(element, 'keypress', testValue, handler);
	}
	
	function bindElementToKeydownValue(element, testValue, handler) {
	  return bindElementToEventValue(element, 'keydown', testValue, handler);
	}
	
	function onElementEnter(element, handler) {
	  return bindElementToKeypressValue(element, KEYS.ENTER, handler);
	}
	
	function onElementEscape(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.ESCAPE, handler);
	}
	
	function onElementSpace(element, handler) {
	  return bindElementToKeypressValue(element, KEYS.SPACE, handler);
	}
	
	function onElementLeft(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.LEFT, handler);
	}
	
	function onElementRight(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.RIGHT, handler);
	}
	
	function onElementUp(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.UP, handler);
	}
	
	function onElementDown(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.DOWN, handler);
	}
	
	function onElementHome(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.HOME, handler);
	}
	
	function onElementEnd(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.END, handler);
	}
	
	function onElementPageUp(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.PAGE_UP, handler);
	}
	
	function onElementPageDown(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.PAGE_DOWN, handler);
	}
	
	function onElementF10(element, handler) {
	  return bindElementToKeydownValue(element, KEYS.F10, handler);
	}
	
	function isAlphaNumeric(charCode) {
	  return charCode >= 48 && charCode <= 90 /* numbers, uppercase letters */
	  || charCode >= 97 && charCode <= 122 /* lowercase letters */;
	}
	
	function onElementCharacter(element, handler) {
	  function localHandler(e) {
	    var charCode = e.which;
	    if (isAlphaNumeric(charCode)) {
	      handler(e);
	    }
	  }
	
	  element.addEventListener('keypress', localHandler);
	  return function () {
	    element.removeEventListener('keypress', localHandler);
	  };
	}
	
	function trapEnter(element) {
	  onElementEnter(element, function (e) {
	    e.stopPropagation();
	    e.preventDefault();
	  });
	}
	

Note: No additional JavaScript initialization code is necessary for this pattern. All elements with class="deque-expander" will be initialized automatically by the external JavaScript file.

CSS

Required: The complete CSS file (for all patterns in the library): deque-patterns.min.css

Key styles within the CSS file (other styles may also be necessary):


.deque-expander .deque-expander-summary {
  display: block;
  border: 0;
  padding: 14px 36px 14px 12px;
  color: #000000;
  left: 0;
  text-align: left;
  background: rgba(0, 0, 0, 0.05);
}
.deque-expander .deque-expander-summary::-webkit-details-marker {
  display: none;
}
.deque-expander .deque-expander-summary .toggle-indicator:before {
  background: none;
  content: '\E70D';
  font-family: 'mwf-glyphs';
  color: #5e5e5e;
  float: left;
  font-size: 1em;
  font-weight: bold;
  margin: 0 10px 0 0;
  padding: 0;
  text-align: center;
  width: 20px;
}
.deque-expander .deque-expander-summary:hover {
  cursor: default;
  outline: 1px solid #990000;
  background-color: rgba(0, 0, 0, 0.1);
}
.deque-expander .deque-expander-summary:active {
  cursor: default;
  background: rgba(0, 0, 0, 0.3);
  outline: 1px solid #990000;
}
.deque-expander .deque-expander-summary:focus {
  outline: 1px dashed #000000;
  background: rgba(0, 0, 0, 0.1);
}
.deque-expander .deque-expander-content {
  clear: both;
  padding: 14px 36px 14px 12px;
}
.deque-expander .deque-expander-summary[aria-expanded=true] .toggle-indicator:before,
.deque-expander.open .deque-expander-summary[aria-expanded=true] .toggle-indicator:before {
  content: '\E70E';
}

Fonts

Note: You will need to edit the src for font-family:'mwf-glyphs' in the external CSS file.

Implementation Instructions

Step 1: Add Dependencies

Add deque-patterns.min.css in the <head> of the document.

<link rel="stylesheet" type="text/css" href="deque-patterns.min.css"></li>

Add a script link to deque-patterns.min.js to the bottom of the page.

<script type="text/javascript" src="deque-patterns.min.js"></script>

Step 2: Add HTML

  • Wrap the each individaul expander HTML in a <details> container with class="deque-expander", for styling purposes and to allow the javascript to initialize all the expanders on the page.
    • Add a <summary> container with class="deque-expander-summary". The inner text will be the title of the expander.
    • Create a <div> or <span> container with class="deque-expander-content" for the content that will be exposed when the regions are expanded.