Reputation: 155
I am trying to create an accessible accordion and I'm currently testing for keyboard navigation. When I start tabbing through the accordion, it will focus on links, buttons, checkboxes, etc.. even though the panel is hidden.
I know that the focusable elements are being targeted since my panels use height: 0 instead of display: none. I'm using height for transitions.
The only solution I can think of is selecting all of the focusable elements in a panel and apply tabindex="-1"
on them whenever the panel is hidden. Is that weird or is there a better way for me to approach this?
Something like this:
focusableElms = panel.querySelectorAll("a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex='0']");
var focusableElm;
for (a = (focusableElms.length - 1); a >= 0; a--) {
focusableElm = focusableElms[a];
focusableElm.setAttribute("tabindex", "-1");
}
Upvotes: 3
Views: 2162
Reputation: 24865
Assuming that you don't have any positive tabindex
s set then the above would work. However if you do have a tabindex
set (which you shouldn't really) then it is a bit more complex.
The other thing is using <details>
and <summary>
will make your application more accessible.
As @CBroe mentioned in the comments, using transitionend
would be better that setTimeout
. I have been living in the stone age thinking it didn't have good support but always looked at the wrong item on caniuse.com.
First lets get the appropriate HTML as it gives us some powerful features out of the box in modern browsers.
<details>
and <summary>
automatically give you loads of features. They automatically associate controls (aria-controls
equivalent), they are clean markup, they automatically have open and close features in most browsers as a fallback for when your JavaScript fails etc.
I have covered these before so you can read more about <details>
and <summary>
in this answer I gave.
<details>
<summary>Item 1</summary>
<p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Ipsum, reiciendis!</p>
</details>
<details>
<summary>Item 2</summary>
<p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Ipsum, reiciendis!</p>
</details>
The easy way to do this is to use JavaScript to change the display property after your animation is complete (and unhide it before it starts) using display: none
.
So if your animation is 1 second you simply set the display: block
before adding any class that triggers the height animation.
To close you trigger the height animation (removing your class) and use setTimeout
for 1 second to then trigger display: none
.
Obviously this does have issues as someone could end up tabbing into the accordion panel as it goes to height 0, then when you set display: none
the page focus position would be lost.
The alternative way is to set tabindex="-1"
as you suggest as you can do that the second you close the accordion.
The below example is taken from this answer I gave on setting tabindex
on an animated section.
It takes into account more than you need (positive tabindex
s, switching animations off using prefers-reduced-motion
, the fact that content-editable
could be used etc.) but should give you the info you need.
It is a bit rough around the edges but should give you a good grounding to start from.
I have put plenty of comments in the code so hopefully you will understand which parts are applicable to you and can adapt it to using <details>
and <summary>
to complete your solution.
var content = document.getElementById('contentDiv');
var btn = document.getElementById('btn_toggle');
var animationDelay = 2000;
//We should account for people with vestibular motion disorders etc. if they have indicated they prefer reduced motion. We set the animation time to 0 seconds.
var motionQuery = matchMedia('(prefers-reduced-motion)');
function handleReduceMotionChanged() {
if (motionQuery.matches) {
animationDelay = 0;
} else {
animationDelay = 2000;
}
}
motionQuery.addListener(handleReduceMotionChanged);
handleReduceMotionChanged();
//the main function for setting the tabindex to -1 for all children of a parent with given ID (and reversing the process)
function hideOrShowAllInteractiveItems(parentDivID){
//a list of selectors for all focusable elements.
var focusableItems = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', '[tabindex]:not([disabled])', '[contenteditable=true]:not([disabled])'];
//build a query string that targets the parent div ID and all children elements that are in our focusable items list.
var queryString = "";
for (i = 0, leni = focusableItems.length; i < leni; i++) {
queryString += "#" + parentDivID + " " + focusableItems[i] + ", ";
}
queryString = queryString.replace(/,\s*$/, "");
var focusableElements = document.querySelectorAll(queryString);
for (j = 0, lenj = focusableElements.length; j < lenj; j++) {
var el = focusableElements[j];
if(!el.hasAttribute('data-modified')){ // we use the 'data-modified' attribute to track all items that we have applied a tabindex to (as we can't use tabindex itself).
// we haven't modified this element so we grab the tabindex if it has one and store it for use later when we want to restore.
if(el.hasAttribute('tabindex')){
el.setAttribute('data-oldTabIndex', el.getAttribute('tabindex'));
}
el.setAttribute('data-modified', true);
el.setAttribute('tabindex', '-1'); // add `tabindex="-1"` to all items to remove them from the focus order.
}else{
//we have modified this item so we want to revert it back to the original state it was in.
el.removeAttribute('tabindex');
if(el.hasAttribute('data-oldtabindex')){
el.setAttribute('tabindex', el.getAttribute('data-oldtabindex'));
el.removeAttribute('data-oldtabindex');
}
el.removeAttribute('data-modified');
}
}
}
btn.addEventListener('click', function(){
contentDiv.className = contentDiv.className !== 'show' ? 'show' : 'hide';
if (contentDiv.className === 'show') {
content.setAttribute('aria-hidden', false);
setTimeout(function(){
contentDiv.style.display = 'block';
hideOrShowAllInteractiveItems('contentDiv');
},0);
}
if (contentDiv.className === 'hide') {
content.setAttribute('aria-hidden', true);
hideOrShowAllInteractiveItems('contentDiv');
setTimeout(function(){
contentDiv.style.display = 'none';
},animationDelay); //using the animation delay set based on the users preferences.
}
});
@keyframes in {
0% { transform: scale(0); opacity: 0; visibility: hidden; }
100% { transform: scale(1); opacity: 1; visibility: visible; }
}
@keyframes out {
0% { transform: scale(1); opacity: 1; visibility: visible; }
100% { transform: scale(0); opacity: 0; visibility: hidden; }
}
#contentDiv {
background: grey;
color: white;
padding: 16px;
margin-bottom: 10px;
}
#contentDiv.show {
animation: in 2s ease both;
}
#contentDiv.hide {
animation: out 2s ease both;
}
/*****We should account for people with vestibular motion disorders etc. if they have indicated they prefer reduced motion. ***/
@media (prefers-reduced-motion) {
#contentDiv.show,
#contentDiv.hide{
animation: none;
}
}
<div id="contentDiv" class="show">
<p>Some information to be hidden</p>
<input />
<button>a button</button>
<button tabindex="1">a button with a positive tabindex that needs restoring</button>
</div>
<button id="btn_toggle"> Hide Div </button>
Upvotes: 2