avernet
avernet

Reputation: 31763

Getting screen reader to read new content added with JavaScript

When a web page is loaded, screen readers (like the one that comes with OS X, or JAWS on Windows) will read the content of the whole page. But say your page is dynamic, and as users performing an action, new content gets added to the page. For the sake of simplicity, say you display a message somewhere in a <span>. How can you get the screen reader to read that new message?

Upvotes: 41

Views: 52181

Answers (3)

Andy
Andy

Reputation: 6170

It really depends whether you are just adding some messages or replacing large parts of the page.

Messages

There are Aria Live Regions, which announce any change to their contents. This is very useful for status messages and sometimes even used with visually hidden live regions to only address screen reader users.

<button onclick="document.querySelector('#statusbar').innerHTML = new Date().toString()">Update</button>

<div id="statusbar" aria-live="assertive"></div>

The aria-live attribute establishes a live region and its value is a politeness setting, which regulates how likely it is the change will interrupt what is currently being spoken by the screen reader.

Another classic example is inline validation of form fields, where the alert role, a live region, is used to immediately announce the error message to the user:

<label>Day of the week we hate
    <input type="text" aria-describedby="error">
</label>
<div role="alert" id="error" hidden>only Monday is permitted</div>

Large Parts of Content

When JavaScript is changing large parts of the site, like in single page applications, putting everything inside a live region would be overkill and actually very annoying.

To let the user know that content changed after activating a trigger, two approaches exist:

  1. A new state of the trigger is announced, implying that the user can simply continue reading to find the new content
  2. Focus is put either onto the element who’s content changed, or on the first interactive element inside

Simply read on

The first case would be applied if the role of the trigger (or other status information) makes it clear that a content change will happen, so it’s expected.

A classic example is the accordion. It has aria-expanded state, which communicates whether its contents are currently visible or not. If they are, the user will simply continue reading, because contents should follow immediately after.

toggleAccordion = e => {
  const target = document.getElementById(e.currentTarget.getAttribute('aria-controls'));
  e.currentTarget.setAttribute('aria-expanded', ! target.toggleAttribute('hidden'));
}
<!-- soon to be replaced by <details> and <summary> --> 
<button aria-expanded="false" aria-controls="accordion-content" onclick="toggleAccordion(event)">2.1 First Rule of ARIA Use</button>
<blockquote id="accordion-content" hidden>
<p>If you can use a native HTML element [HTML51] or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so. […]</p>
</blockquote>

Put focus on the new content

In the second case focus is set programmatically elsewhere, so that element will be announced. This is particularly helpful if it’s parent elements have grouping roles, so their names will be announced as well, as in the case of a modal dialog.

Another example would be a single page application’s navigation, where the single navigation items are still navigated by means of tab.

To be able to programmatically focus a non-interactive element, but not manually, tabindex="-1" is necessary. Focussing the headline is a best practice.

/* some sort of SPA router */
document.querySelectorAll('nav a').forEach(a => a.addEventListener('click', e => {  
  // hide all visible contents
  document.querySelectorAll('main > :not([hidden])').forEach(c => c.hidden = true);
  document.querySelectorAll('[aria-current]').forEach(c => c.removeAttribute('aria-current'));
  
  // show selected content
  const content = document.querySelector(e.currentTarget.getAttribute('href'));
  content.hidden = false;
  content.querySelector('h1').focus();
  e.currentTarget.setAttribute('aria-current', 'page');
}));
a[aria-current] { font-weight: bold }
<nav>
  <ul>
    <li><a href="#page-1" aria-current="page">Page 1</a></li>
    <li><a href="#page-2">Page 2</a></li>
  </ul>
</nav>

<main>
  <div id="page-1">
    <h1 tabindex="-1">Page 1</h1>
    <p>Many lines of content to follow</p>
  </div>
  <div id="page-2" hidden>
    <h1 tabindex="-1">Page 2</h1>
    <p>Many lines of content to follow</p>
  </div>
</main>

Upvotes: 4

Courtney Christensen
Courtney Christensen

Reputation: 9565

The WAI-ARIA specification defines several ways by which screen readers can "watch" a DOM element. The best supported method is the aria-live attribute. It has modes off, polite,assertive and rude. The higher the level of assertiveness, the more likely it is to interrupt what is currently being spoken by the screen reader.

The following has been tested with NVDA under Firefox 3 and Firefox 4.0b9:

<!DOCTYPE html>
<html>
<head>
  <script src="js/jquery-1.4.2.min.js"></script>
</head>
<body>
  <button onclick="$('#statusbar').html(new Date().toString())">Update</button>
  <div id="statusbar" aria-live="assertive"></div>
</body>

The same thing can be accomplished with WAI-ARIA roles role="status" and role="alert". I have had reports of incompatibility, but have not been able to reproduce them.

<div id="statusbar" role="status">...</div>

Upvotes: 40

Marcy Sutton
Marcy Sutton

Reputation: 917

Here is an adapted real world example -- this up-level markup has already been converted from an unordered list with links into a select menu via JS. The real code is a lot more complex and obviously could not be included in its entirety, so remember this will have to be rethought for production use. For the select menu to be made keyboard accessible, we registered the keypress & onchange events and fired the AJAX call when users tabbed off of the list (beware of browser differences in timing of the onchange event). This was a serious PITA to make accessible, but it IS possible.

  //  HTML

  <!-- select element with content URL -->
  <label for="select_element">State</label>
  <select id="select_element">
     <option value="#URL_TO_CONTENT_PAGE#" rel="alabama">Alabama</option>
  </select>
  <p id="loading_element">Content Loading</p>

  <!-- AJAX content loads into this container -->
  <div id="results_container"></div>


  // JAVASCRIPT (abstracted from a Prototype class, DO NOT use as-is)

  var selectMenu = $('select_element');
  var loadingElement = $('loading_element');
  var resultsContainer = $('results_container');

 // listen for keypress event (omitted other listeners and support test logic)
  this.selectMenu.addEventListener('keypress', this.__keyPressDetector, false);


 /* event callbacks */

 // Keypress listener

  __keyPressDetector:function(e){

    // if we are arrowing through the select, enable the loading element
    if(e.keyCode === 40 || e.keyCode === 38){
        if(e.target.id === 'select_element'){
            this.loadingElement.setAttribute('tabIndex','0');
        }
    }
    // if we tab off of the select, send focus to the loading element
    //  while it is fetching data
     else if(e.keyCode === 9){
        if(targ.id === 'select_element' && targ.options[targ.selectedIndex].value !== ''){            
            this.__changeStateDetector(e);

            this.loadingElement.focus();

        }   
    }
}

// content changer (also used for clicks)
__changeStateDetector:function(e){

    // only execute if there is a state change
    if(this.selectedState !== e.target.options[e.target.selectedIndex].rel){

       // get state name and file path
       var stateName = e.target.options[e.target.selectedIndex].rel;
       var stateFile = e.target.options[e.target.selectedIndex].value;

       // get the state file
       this.getStateFile(stateFile);

       this.selectedState = stateName;

    }
}

getStateFile:function(stateFile){
    new Ajax.Request(stateFile, {
        method: 'get',
        onSuccess:function(transport){      

            // insert markup into container
            var markup = transport.responseText;

            // NOTE: select which part of the fetched page you want to insert, 
            // this code was written to grab the whole page and sort later

            this.resultsContainer.update(markup);

            var timeout = setTimeout(function(){

                // focus on new content
               this.resultsContainer.focus();

            }.bind(this), 150);

        }.bind(this)
    });
}

Upvotes: 1

Related Questions