Accessibly show & hide blocks of content.
Failed testing.
The heading lost its properties when role="button" was added. Rebuild required.
Please use peek-a-boo v6 until resolved.
Fully annotated script is available in the demo source or take a look at the Codepen.
Most of the hidden sections on this site are either lists of links or technical content code blocks and make use of the technique described here.
Note this is not strictly an accordion (one drawer open at a time), though modification should be possible.
In version 7 class names are depricated in favour of ARIA attributes wherever possible. Which enforces correct usage.
Mark-up
The actual tags used are strongly recommended for the semantic value, but are not enforced. You may freely change any of them. For example; the container to a div, the heading to a paragraph, the div to an unordered list. For the sake of simplicity we'll refer to them as container, heading, and block.
Mark a heading with data-pab, to act as a JavaScript hook, and set its value to the block id name. In this example "pab-content".
<section>
<h2 data-pab="pab-content">Title text</h2>
<div id="pab-content">
<p>The hidden content.</p>
…
</div>
</section>
The script builds on the core markup retaining the key semantic structure.
The JavaScipt changes the heading behavior to that of an activating button.
ARIA roles are added to notify AT of the expected behavior change, and an SVG icon is added as a visual clue.
<section class="pab_container">
<h2 aria-controls=pab-content
id=pab_0
aria-expanded=false
tabindex=0
role=button
data-pab=pab-content>
<svg class=svg-plus width=38 height=38 viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
<title>Show</title>
<path d="M10.5 19l17 0"/>
<path d="M19 10.5l0 17"/>
</svg>
Title text
<span id=spt_1 class=spot></span>
</h2>
<div aria-labelledby=pab_0
aria-hidden=true
id=pab-content>
<p>The hidden content.</p>
…
</div>
</section>
Optionally predefine the container class "pab-container" to allow the CSS to run on-load rather than after the JavaScript has kicked in. It's always about performance.
Adding aria-expanded="true" to the heading will force the hidden section to be open by default.
Once initialised the script changes the states of the ARIA attributes which are actioned by CSS. No class names were hurt in the making of this module.
CSS
CSS is added at different support levels. Firstly before JavaScript, that's if class="pab_container" is in HTML.
.pab_container {
margin: 1.618rem 0;
border: 1px solid rgba(96,96,128,.1);
position: relative;
background-color: rgba(255,255,255,.3);
transition:
background-color .3s ease-out,
box-shadow .3s ease-out;
}
The code is agnostic to what HTML elements are used but they must be in the set order: container > heading + block.
.pab_container > :first-child {
text-align: left;
font-size: 1.118rem;
padding: 0.618rem .236rem .618rem 2.618rem;
background-color: transparent;
border: 1px solid rgba(255,255,255,.7);
}
.pab_container > :nth-child(2),
.pab_container > :nth-child(3) {
/* :nth-child(3) added so padding is
applied to the clone when
calculating max-height */
padding: 0 1rem;
list-style: none; /* if ul or ol */
}
The rest of the CSS is only actioned if JavaScript has modified the HTML attributes.
The heading becomes the toggle button.
[data-pab][role=button] {
position: relative;
cursor: pointer;
overflow: hidden;
touch-action: manipulation; /* ??? */
transition:
box-shadow 0.2s ease-in,
background-color 0.2s ease-in,
color 0.3s ease-in;
/* reserve space for SVG absolute positioned */
padding-left: 2.618rem;
}
[data-pab][role=button]:hover,
[data-pab][role=button]:focus,
[data-pab][role=button]:active {
color: #236ECE;
background-color: #fff;
}
[data-pab][role=button]:focus {
outline: 0 solid;
}
[data-pab][aria-expanded=true] {
box-shadow: 0 1px 0 rgba(0,0,0,.1);
}
[data-pab] * {
pointer-events: none;
}
The container has CSS added / removed on interactions. Used to highlight the whole container when the heading (button) is hovered or focused.
.pab_btn_hovered,
.pab_btn_focused {
background-color: rgba(255, 255, 255, 0.65);
box-shadow: 0 4px 4px rgba(0,0,0,0.3);
}
The animated SVG plus / minus icon
.svg-plus {
display: block;
position: absolute;
top: calc(50% - 1rem);
left: 0.618rem;
width: 1.8rem;
height: 1.8rem;
margin: 0;
transition: transform .7s ease-out;
pointer-events: none;
}
.svg-plus > path {
stroke-width: 5;
stroke-linecap: square;
stroke: #9F9DB2;
transition:
stroke 0.5s ease-out,
opacity 0.7s ease-out;
}
[aria-expanded=true] > .svg-plus {
transform: rotateZ(360deg);
}
[aria-expanded=true] > .svg-plus > :last-child {
opacity: 0;
}
[data-pab]:hover > .svg-plus > path,
[data-pab]:focus > .svg-plus > path {stroke: #418cec}
You may of noticed a span with class="spot" was also added by the JavaScript. This provides the click or tap feedback animation. A nice touch to my mind.
.spot {
display: block;
position: absolute;
background: rgba(35,110,206,0.35);
border-radius: 50%;
transform: scale(0);
opacity: 1;
-webkit-filter: blur(1rem);
filter: blur(1rem);
}
[aria-expanded=true] .spot {animation: spot-open 0.6s ease-in;}
[aria-expanded=false] .spot {animation: spot-close 0.6s ease-out;}
@keyframes spot-open {
to {
opacity: 0;
transform: scale(2.4);
}
}
@keyframes spot-close {
0% {
opacity: 0;
transform: scale(2.4);
}
100% {
opacity: 1;
transform: scale(0);
}
}
The aria-hidden attribute controls the "hidden" block animation.
Caveat: Avoid vertical margins, or padding, applied directly to the block.
/* Just the open / close animation
Problem is the inaccurate max-height
Which is resolved via JS */
[data-pab] + [aria-hidden] {
overflow: hidden;
opacity: 1;
max-height: 10rem;
visibility: visible;
transition:
visibility 0s ease 0s,
max-height .65s ease-out 0s,
opacity .65s ease-in 0s;
}
[data-pab] + [aria-hidden=true] {
max-height: 0;
opacity: 0;
visibility: hidden;
transition-delay: 1s, 0s, 0s;
}
/* Overide max-height set as an inline style by the JS */
[data-pab] + [style][aria-hidden=true] {
max-height: 0 !important;
}
Hardware acceleration for the transitions, not needed but never hurts.
.spot,
[data-pab] + [aria-hidden] {
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000;
}
Google closure compiled
v7.0 - 1.26KB gzipped (3.19KB uncompressed).
var Pab=function(r,k,t){function e(b){return b&&("true"===b.getAttribute("aria-expanded")||"false"===b.getAttribute("aria-hidden"))}var g="data-pab".replace("data-","")+"_",h=0,l=function(b){var a=b.cloneNode(!0),c=0;a.setAttribute("style","display:block;width:"+b.clientWidth+"px;position:absolute;top:0;left:-999rem;max-height:none;height:auto;visibility:hidden;");b.parentElement.appendChild(a);c=a.clientHeight;b.parentElement.removeChild(a);return c},n=function(b,a,c){b.setAttribute("aria-expanded",
!c);e(a)||(a.style.maxHeight=l(a)+"px");a.setAttribute("aria-hidden",c);m(b)},f=function(b){var a=g+"btn_";switch(b.type){case "focus":b.target.parentNode.classList.add(a+"focused");break;case "blur":b.target.parentNode.classList.remove(a+"focused");break;case "mouseover":b.target.parentNode.classList.add(a+"hovered");break;case "mouseout":b.target.parentNode.classList.remove(a+"hovered")}},u=function(b,a){var c=a.clientWidth,d=b.offsetX-c/2,e=b.offsetY-c/2,f=document.getElementById(a.spotId);"keydown"===
b.type&&(d=0,e=-(c-a.clientHeight)/2);window.requestAnimationFrame(function(){f.setAttribute("style","top:"+e+"px;left:"+d+"px;height:"+c+"px;width:"+c+"px")})},m=function(b){var a=b.getElementsByTagName("title");a&&a[0]&&(a[0].innerHTML=e(b)?"Hide":"Show")},p=function(b){var a=b.target,c,d;a&&(c=document.getElementById(a.getAttribute("aria-controls")))&&(b.preventDefault(),d=e(a),n(a,c,d),u(b,a),b=a.id,c=d?!d:c.id,k&&(c?localStorage["target_"+b]=c:localStorage.removeItem("target_"+b)))},v=function(b){13!==
b.which&&32!==b.which||p(b)},w=function(b){var a=b.cloneNode(!0);a.innerHTML.match("svg")||(a.innerHTML=r+a.innerHTML,m(a),requestAnimationFrame(function(){b.parentElement.replaceChild(a,b)}));return a},x=function(b){var a=b.cloneNode(!0);a.innerHTML.match('id="spt_')||(a.innerHTML+="<span id=spt_"+h+" class=spot></span>",a.spotId="spt_"+h,requestAnimationFrame(function(){b.parentElement&&b.parentElement.replaceChild(a,b)}));return a},q=function(){Array.prototype.slice.call(document.querySelectorAll("[data-pab]")).reduce(function(b,
a){var c=a.getAttribute("data-pab");if(c=document.getElementById(c)){var d=a;d.setAttribute("role","button");d.setAttribute("tabindex",0);d.hasAttribute("aria-expanded")||d.setAttribute("aria-expanded",!1);d.id||d.setAttribute("id",g+h++);d.setAttribute("aria-controls",d.getAttribute("data-pab"));(d=a.parentElement)&&d.classList.add(g+"container");a=w(a);d=a=x(a);c.setAttribute("aria-hidden",!e(d));c.setAttribute("aria-labelledby",d.id);c=a;c.addEventListener("focus",f,!1);c.addEventListener("blur",
f,!1);c.addEventListener("mouseout",f,!1);c.addEventListener("mouseover",f,!1);c.addEventListener("click",p,!1);c.addEventListener("keydown",v,!1)}return!0},{});return!0};q();(function(){k&&setTimeout(function(){for(var b,a,c=localStorage.length;c--;)b=localStorage.key(c),b.match("target_")&&(a=localStorage.getItem(b),b=document.getElementById(b.replace("target_","")),(a=document.getElementById(a))&&b&&n(b,a,!1))},1E3)})();window.addEventListener("resize",t(function(){for(var b=document.querySelectorAll("[data-pab]"),
a=b.length,c;a--;)if(c=document.getElementById(b[a].getAttribute("data-pab")))c.style.maxHeight=l(c)+"px"},500));return{addToggles:q}}(svg_plus,useLocalStorage,debounce);
// Dynamically check for new toggles and add.
// Pab.addToggles();
Still to do
Change from using on-click to on-touch-end to improve responsiveness on touch devices.
Work this version back into the site content. Though I believe this is the point where NPM comes in. Time to upscale production techniques…
Social links and email client: