Reputation: 41
I've been trying to make my current expandable accordions meet Level AA of the W3C Web Content Accessibility Guidelines by being both keyboard accessible and screen reader accessible.
I am not super familiar with JavaScript/jQuery so I have been doing a lot of guess-and-check so far.
I have accomplished the following:
But apparently I'm missing the following:
Here is the CodePen I have been using: https://codepen.io/kwhytock/pen/Ozzopr I included all the jQuery UI code, but the Accordion-focused code starts at line 2516.
$(function() {
$("#accordion:nth-child(1n)").accordion({
collapsible: true
});
$("#accordion:nth-child(1n)").accordion({
active: false
});
});
var widgetsAccordion = $.widget("ui.accordion", {
version: "1.12.1",
options: {
active: 0,
animate: {},
classes: {
"ui-accordion-header": "ui-corner-top",
"ui-accordion-header-collapsed": "ui-corner-all",
"ui-accordion-content": "ui-corner-bottom"
},
collapsible: false,
event: "click",
header: ".accordionTitle",
heightStyle: "auto",
// Callbacks
activate: null,
beforeActivate: null
},
hideProps: {
borderTopWidth: "hide",
borderBottomWidth: "hide",
paddingTop: "hide",
paddingBottom: "hide",
height: "hide"
},
showProps: {
borderTopWidth: "show",
borderBottomWidth: "show",
paddingTop: "show",
paddingBottom: "show",
height: "show"
},
_create: function() {
var options = this.options;
this.prevShow = this.prevHide = $();
this._addClass("ui-accordion", "ui-widget ui-helper-reset");
this.element.attr("role", "tablist");
// Don't allow collapsible: false and active: false / null
if (!options.collapsible && (options.active === false || options.active == null)) {
options.active = 0;
}
this._processPanels();
// handle negative values
if (options.active < 0) {
options.active += this.headers.length;
}
this._refresh();
},
_getCreateEventData: function() {
return {
header: this.active,
panel: !this.active.length ? $() : this.active.next()
};
},
_createIcons: function() {
var icon, children,
icons = this.options.icons;
if (icons) {
icon = $("<span>");
this._addClass(icon, "ui-accordion-header-icon", "ui-icon " + icons.header);
icon.prependTo(this.headers);
children = this.active.children(".ui-accordion-header-icon");
this._removeClass(children, icons.header)
._addClass(children, null, icons.activeHeader)
._addClass(this.headers, "ui-accordion-icons");
}
},
_destroyIcons: function() {
this._removeClass(this.headers, "ui-accordion-icons");
this.headers.children(".ui-accordion-header-icon").remove();
},
_destroy: function() {
var contents;
// Clean up main element
this.element.removeAttr("role");
// Clean up headers
this.headers
.removeAttr("role aria-expanded aria-selected aria-controls tabIndex")
.removeUniqueId();
this._destroyIcons();
// Clean up content panels
contents = this.headers.next()
.css("display", "")
.removeAttr("role aria-hidden aria-labelledby")
.removeUniqueId();
if (this.options.heightStyle !== "content") {
contents.css("height", "");
}
},
_setOption: function(key, value) {
if (key === "active") {
// _activate() will handle invalid values and update this.options
this._activate(value);
return;
}
if (key === "event") {
if (this.options.event) {
this._off(this.headers, this.options.event);
}
this._setupEvents(value);
}
this._super(key, value);
// Setting collapsible: false while collapsed; open first panel
if (key === "collapsible" && !value && this.options.active === false) {
this._activate(0);
}
if (key === "icons") {
this._destroyIcons();
if (value) {
this._createIcons();
}
}
},
_setOptionDisabled: function(value) {
this._super(value);
this.element.attr("aria-disabled", value);
// Support: IE8 Only
// #5332 / #6059 - opacity doesn't cascade to positioned elements in IE
// so we need to add the disabled class to the headers and panels
this._toggleClass(null, "ui-state-disabled", !!value);
this._toggleClass(this.headers.add(this.headers.next()), null, "ui-state-disabled", !!value);
},
_keydown: function(event) {
if (event.altKey || event.ctrlKey) {
return;
}
var keyCode = $.ui.keyCode,
length = this.headers.length,
currentIndex = this.headers.index(event.target),
toFocus = true;
switch (event.keyCode) {
case keyCode.RIGHT:
case keyCode.TAB:
if (event.shiftKey && event.keyCode == 9) {
//shift was down when tab was pressed
}
toFocus = this.headers[(currentIndex - 1) % length];
case keyCode.DOWN:
toFocus = this.headers[(currentIndex + 1)];
break;
case keyCode.LEFT:
case keyCode.UP:
toFocus = this.headers[(currentIndex - 1 + length) % length];
break;
case keyCode.SPACE:
case keyCode.ENTER:
this._eventHandler(event);
break;
case keyCode.HOME:
toFocus = this.headers[0];
break;
case keyCode.END:
toFocus = this.headers[length - 1];
break;
}
if (toFocus) {
$(event.target).attr("tabIndex", -1);
$(toFocus).attr("tabIndex", 0);
$(toFocus).trigger("focus");
event.preventDefault();
}
},
_panelKeyDown: function(event) {
if (event.keyCode === $.ui.keyCode.UP && event.ctrlKey) {
$(event.currentTarget).prev().trigger("focus");
}
},
refresh: function() {
var options = this.options;
this._processPanels();
// Was collapsed or no panel
if ((options.active === false && options.collapsible === true) ||
!this.headers.length) {
options.active = false;
this.active = $();
// active false only when collapsible is true
} else if (options.active === false) {
this._activate(0);
// was active, but active panel is gone
} else if (this.active.length && !$.contains(this.element[0], this.active[0])) {
// all remaining panel are disabled
if (this.headers.length === this.headers.find(".ui-state-disabled").length) {
options.active = false;
this.active = $();
// activate previous panel
} else {
this._activate(Math.max(0, options.active - 1));
}
// was active, active panel still exists
} else {
// make sure active index is correct
options.active = this.headers.index(this.active);
}
this._destroyIcons();
this._refresh();
},
_processPanels: function() {
var prevHeaders = this.headers,
prevPanels = this.panels;
this.headers = this.element.find(this.options.header);
this._addClass(this.headers, "ui-accordion-header ui-accordion-header-collapsed",
"ui-state-default");
this.panels = this.headers.next().filter(":not(.ui-accordion-content-active)").hide();
this._addClass(this.panels, "ui-accordion-content", "ui-helper-reset ui-widget-content");
// Avoid memory leaks (#10056)
if (prevPanels) {
this._off(prevHeaders.not(this.headers));
this._off(prevPanels.not(this.panels));
}
},
_refresh: function() {
var maxHeight,
options = this.options,
heightStyle = options.heightStyle,
parent = this.element.parent();
this.active = this._findActive(options.active);
this._addClass(this.active, "ui-accordion-header-active", "ui-state-active")
._removeClass(this.active, "ui-accordion-header-collapsed");
this._addClass(this.active.next(), "ui-accordion-content-active");
this.active.next().show();
this.headers
.attr("role", "heading")
.attr("type", "button")
.each(function() {
var header = $(this),
headerId = header.uniqueId().attr("id"),
panel = header.next(),
panelId = panel.uniqueId().attr("id");
header.attr("aria-controls", panelId);
panel.attr("aria-labelledby", headerId);
})
.next()
.attr("role", "region");
this.headers
.not(this.active)
.attr({
"aria-selected": "false",
"aria-expanded": "false",
tabIndex: -1
})
.next()
.attr({
"aria-hidden": "true"
})
.hide();
// Make sure at least one header is in the tab order
if (!this.active.length) {
this.headers.eq(0).attr("tabIndex", 0);
} else {
this.active.attr({
"aria-selected": "true",
"aria-expanded": "true",
tabIndex: 0
})
.next()
.attr({
"aria-hidden": "false"
});
}
this._createIcons();
this._setupEvents(options.event);
if (heightStyle === "fill") {
maxHeight = parent.height();
this.element.siblings(":visible").each(function() {
var elem = $(this),
position = elem.css("position");
if (position === "absolute" || position === "fixed") {
return;
}
maxHeight -= elem.outerHeight(true);
});
this.headers.each(function() {
maxHeight -= $(this).outerHeight(true);
});
this.headers.next()
.each(function() {
$(this).height(Math.max(0, maxHeight -
$(this).innerHeight() + $(this).height()));
})
.css("overflow", "auto");
} else if (heightStyle === "auto") {
maxHeight = 0;
this.headers.next()
.each(function() {
var isVisible = $(this).is(":visible");
if (!isVisible) {
$(this).show();
}
maxHeight = Math.max(maxHeight, $(this).css("height", "").height());
if (!isVisible) {
$(this).hide();
}
})
.height(maxHeight);
}
},
_activate: function(index) {
var active = this._findActive(index)[0];
// Trying to activate the already active panel
if (active === this.active[0]) {
return;
}
// Trying to collapse, simulate a click on the currently active header
active = active || this.active[0];
this._eventHandler({
target: active,
currentTarget: active,
preventDefault: $.noop
});
},
_findActive: function(selector) {
return typeof selector === "number" ? this.headers.eq(selector) : $();
},
_setupEvents: function(event) {
var events = {
keydown: "_keydown"
};
if (event) {
$.each(event.split(" "), function(index, eventName) {
events[eventName] = "_eventHandler";
});
}
this._off(this.headers.add(this.headers.next()));
this._on(this.headers, events);
this._on(this.headers.next(), {
keydown: "_panelKeyDown"
});
this._hoverable(this.headers);
this._focusable(this.headers);
},
_eventHandler: function(event) {
var activeChildren, clickedChildren,
options = this.options,
active = this.active,
clicked = $(event.currentTarget),
clickedIsActive = clicked[0] === active[0],
collapsing = clickedIsActive && options.collapsible,
toShow = collapsing ? $() : clicked.next(),
toHide = active.next(),
eventData = {
oldHeader: active,
oldPanel: toHide,
newHeader: collapsing ? $() : clicked,
newPanel: toShow
};
event.preventDefault();
if (
// click on active header, but not collapsible
(clickedIsActive && !options.collapsible) ||
// allow canceling activation
(this._trigger("beforeActivate", event, eventData) === false)) {
return;
}
options.active = collapsing ? false : this.headers.index(clicked);
// When the call to ._toggle() comes after the class changes
// it causes a very odd bug in IE 8 (see #6720)
this.active = clickedIsActive ? $() : clicked;
this._toggle(eventData);
// Switch classes
// corner classes on the previously active header stay after the animation
this._removeClass(active, "ui-accordion-header-active", "ui-state-active");
if (options.icons) {
activeChildren = active.children(".ui-accordion-header-icon");
this._removeClass(activeChildren, null, options.icons.activeHeader)
._addClass(activeChildren, null, options.icons.header);
}
if (!clickedIsActive) {
this._removeClass(clicked, "ui-accordion-header-collapsed")
._addClass(clicked, "ui-accordion-header-active", "ui-state-active");
if (options.icons) {
clickedChildren = clicked.children(".ui-accordion-header-icon");
this._removeClass(clickedChildren, null, options.icons.header)
._addClass(clickedChildren, null, options.icons.activeHeader);
}
this._addClass(clicked.next(), "ui-accordion-content-active");
}
},
_toggle: function(data) {
var toShow = data.newPanel,
toHide = this.prevShow.length ? this.prevShow : data.oldPanel;
// Handle activating a panel during the animation for another activation
this.prevShow.add(this.prevHide).stop(true, true);
this.prevShow = toShow;
this.prevHide = toHide;
if (this.options.animate) {
this._animate(toShow, toHide, data);
} else {
toHide.hide();
toShow.show();
this._toggleComplete(data);
}
toHide.attr({
"aria-hidden": "true"
});
toHide.prev().attr({
"aria-selected": "false",
"aria-expanded": "false"
});
// if we're switching panels, remove the old header from the tab order
// if we're opening from collapsed state, remove the previous header from the tab order
// if we're collapsing, then keep the collapsing header in the tab order
if (toShow.length && toHide.length) {
toHide.prev().attr({
"tabIndex": -1,
"aria-expanded": "false"
});
} else if (toShow.length) {
this.headers.filter(function() {
return parseInt($(this).attr("tabIndex"), 10) === 0;
})
.attr("tabIndex", -1);
}
toShow
.attr("aria-hidden", "false")
.prev()
.attr({
"aria-selected": "true",
"aria-expanded": "true",
tabIndex: 0
});
},
_animate: function(toShow, toHide, data) {
var total, easing, duration,
that = this,
adjust = 0,
boxSizing = toShow.css("box-sizing"),
down = toShow.length &&
(!toHide.length || (toShow.index() < toHide.index())),
animate = this.options.animate || {},
options = down && animate.down || animate,
complete = function() {
that._toggleComplete(data);
};
if (typeof options === "number") {
duration = options;
}
if (typeof options === "string") {
easing = options;
}
// fall back from options to animation in case of partial down settings
easing = easing || options.easing || animate.easing;
duration = duration || options.duration || animate.duration;
if (!toHide.length) {
return toShow.animate(this.showProps, duration, easing, complete);
}
if (!toShow.length) {
return toHide.animate(this.hideProps, duration, easing, complete);
}
total = toShow.show().outerHeight();
toHide.animate(this.hideProps, {
duration: duration,
easing: easing,
step: function(now, fx) {
fx.now = Math.round(now);
}
});
toShow
.hide()
.animate(this.showProps, {
duration: duration,
easing: easing,
complete: complete,
step: function(now, fx) {
fx.now = Math.round(now);
if (fx.prop !== "height") {
if (boxSizing === "content-box") {
adjust += fx.now;
}
} else if (that.options.heightStyle !== "content") {
fx.now = Math.round(total - toHide.outerHeight() - adjust);
adjust = 0;
}
}
});
},
_toggleComplete: function(data) {
var toHide = data.oldPanel,
prev = toHide.prev();
this._removeClass(toHide, "ui-accordion-content-active");
this._removeClass(prev, "ui-accordion-header-active")
._addClass(prev, "ui-accordion-header-collapsed");
// Work around for rendering bug in IE (#5421)
if (toHide.length) {
toHide.parent()[0].className = toHide.parent()[0].className;
}
this._trigger("activate", null, data);
}
});
var safeActiveElement = $.ui.safeActiveElement = function(document) {
var activeElement;
// Support: IE 9 only
// IE9 throws an "Unspecified error" accessing document.activeElement from an <iframe>
try {
activeElement = document.activeElement;
} catch (error) {
activeElement = document.body;
}
// Support: IE 9 - 11 only
// IE may return null instead of an element
// Interestingly, this only seems to occur when NOT in an iframe
if (!activeElement) {
activeElement = document.body;
}
// Support: IE 11 only
// IE11 returns a seemingly empty object in some cases when accessing
// document.activeElement from an <iframe>
if (!activeElement.nodeName) {
activeElement = document.body;
}
return activeElement;
};
.accordionTitle {
border: 1px solid #ccc;
margin: 5px 0 0 0;
font-weight: 200 !important;
font-size: 1.15em;
background-color: #F8F8F8;
padding: 1em 0.5em;
text-decoration: none;
color: #000;
-webkit-transition: background-color 0.5s ease-in-out;
transition: background-color 0.5s ease-in-out;
}
.accordionTitle:before {
content: "";
font-size: 1.5em;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid;
float: left;
margin: 0.475em;
margin-right: 0.55em;
-webkit-transition: -webkit-transform 0.3s ease-in-out;
transition: -webkit-transform 0.3s ease-in-out;
transition: transform 0.3s ease-in-out;
transition: transform 0.3s ease-in-out, -webkit-transform 0.3s ease-in-out;
-webkit-transform: rotate(-90deg);
transform: rotate(-90deg);
}
.accordionTitle[aria-selected="true"]:before {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
.accordionTitle:focus,
.accordionTitle:hover {
background-color: #dadada;
}
.ui-accordion-content {
height: auto !important;
overflow: hidden;
padding: 1.5em 1.5em;
border: 1px solid #ccc;
}
[aria-pressed=true],
[aria-expanded=true] {
background-color: #f9f9f9;
}
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script src="http://sh101ftp.net/imgload/wordpress/jquery-ui.js"></script>
<script src="http://sh101ftp.net/imgload/wordpress/NewCustomCodeJS.js"></script>
<h2 id="question1" class="question"><span class="dropcap dropcap3" style="color: #127eb6;">1</span> <span style="color: #404040;">What might help you make physical activity an ongoing thing?</span></h2>
<div id="accordion" role="presentation">
<h3 class="accordionTitle"><strong>A.</strong> Option A</h3>
<div>
<p>This plan is practical, social, and could work well for both of you. Some disabilities an</span>d other pre-existing conditions have implications for working out. Your friend knows her own body and can seek medical clearance if needed. This is her call.</p>
<p><u><a href="http://www.prochange.com/college-health" target="_blank" rel="noopener noreferrer">liveWell program (Pro-Change Behavior Systems, Inc.)</a></u></p>
</div>
<h3 class="accordionTitle"><strong>B.</strong> Option B</h3>
<div>
<p>Self-consciousness can be a barrier to working out, yes. Candy hasn’t said that’s a problem for her, though. Many people with disabilities are marginalized and excluded. We all do better when we’re socially integrated into our communities. For example, people with robust social networks (supportive friends and family) experience lower rates of chronic disease and longer lives, and more job opportunities, according to a 2011 report from the National Research Council.</p>
<p><u><a href="http://november-project.com/" target="_blank" rel="noopener noreferrer">November Project</a></u></p>
<p><u><a href="https://www.meetup.com/" target="_blank" rel="noopener noreferrer">Meetup</a></u></p>
</div>
<h3 class="accordionTitle"><strong>C.</strong> Option C</h3>
<div>
<p>Disability advocates call this “inspiration porn.” It’s condescending. Why should you be amazed that Candy wants to do something with her life?</p>
</div>
</div>
Upvotes: 1
Views: 9703
Reputation: 17535
You're doing way too much work. I say that based on seeing code such as:
<div id="accordion" role="presentation">
A <div>
, by default, does not have a role so setting role="presentation"
is superfluous and just bloats your code.
Additionally, since tabbing through your codepen example seems to be very confused (you can't tab backwards), your dynamic use of tabindex
is off. In general, when using native HTML elements, such as <button>
, you don't have to mess with tabindex
.
Once you start throwing in ARIA attributes and tabindex
, it starts getting very messy. I would recommend building a simple example so you can see how it works properly. Start with the WAI-ARIA Authoring Practices 1.1 section on Accordions. It has a working example.
Basically, an accordion consists of:
Try these simple steps first:
The title of each accordion header is contained in a <button>
or an element with role="button"
.
Each accordion header button is wrapped in an <hX>
element with a level that is appropriate for the information architecture of the page. The button element is the only element inside the heading element.
If the accordion panel associated with an accordion header is visible, the header button element has aria-expanded
set to true. If the panel is not visible, aria-expanded
is set to false. The panel itself should have aria-hidden
set appropriately or hidden with CSS ("display:none"
)
The accordion header button element should have aria-controls
set to the ID of the element containing the accordion panel content.
The accordion panel has role="region"
and aria-labelledby
with a value that refers to the button that controls display of the panel.
So you'd have something like this:
<div> <!-- accordion container -->
<h3>
<button id="first" aria-expanded="false" aria-controls="panel1">first accordion title</button>
</h3>
<div id="panel1" role="region" style="display:none;" aria-labelledby= "first">
<!-- contents of your panel -->
</div>
<h3>
<button id="second" aria-expanded="false" aria-controls="panel2">second title</button>
</h3>
<div id="panel2" role="region" style="display:none;" aria-labelledby= "second">
<!-- contents of your panel -->
</div>
</div>
The button's aria-expanded
attribute and panel's display:none
CSS style should be toggled when the button is selected.
This will allow for native tabbing to all the accordion headers (buttons), which in your case were the questions A, B, and C. You don't have to mess with tabindex
because buttons are focusable by default. All you have to do is toggle the button's aria-expanded
attribute and hide/unhide the panel contents. Easy-peasy. It works great with either a keyboard or a screen reader.
Upvotes: 1