Kelsey
Kelsey

Reputation: 41

How can I make my accordion accessible by keyboard + screen readers?

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:

  1. A tab-index order by using the Tab key
  2. The ability to move back and forth using the up/down or left/right keyboard keys
  3. The ability to expand/collapse an accordion by using either Enter or Spacebar

But apparently I'm missing the following:

  1. Cannot navigate backward using "Shift+Tab".
  2. Unable to collapse the expanded toggle as focus moves incorrectly and using shift+tab will not bring focus back to the expanded toggle.
  3. Focus does not move to the links present under the toggles.
  4. Tabs grouping is not present, the screen reader does not read tab 1 of 3, tab 2 of 3, etc.

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

Answers (1)

slugolicious
slugolicious

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:

  • The accordion element – A collection of panels contained within an outer pane (often a list)
  • Accordion header – The labeled area(s) of the accordion panel that are expandable and collapsible
  • Accordion panel – The area (container) that contains the content specific to each header

Try these simple steps first:

  1. The title of each accordion header is contained in a <button> or an element with role="button".

  2. 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.

  3. 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")

  4. The accordion header button element should have aria-controls set to the ID of the element containing the accordion panel content.

  5. 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

Related Questions