11

I'm working on a single-page application (SPA) where we're simulating a multi-page application with HTML 5 history.pushState. It looks fine visually, but it's not behaving correctly in iOS Voiceover. (I assume it wouldn't work in any screen reader, but Voiceover is what I'm trying first.)

Here's an example of the behavior I'm trying to achieve. Here are two ordinary web pages:

1.html

<!DOCTYPE html><html>
<body>This is page 1. <a href=2.html>Click here for page 2.</a></body>
</html>

2.html

<!DOCTYPE html><html>
<body>This is page 2. <a href=1.html>Click here for page 1.</a></body>
</html>

Nice and simple. Voiceover reads it like this:

Web page loaded. This is page 1.
[swipe right] Click here for page 2. Link.
[double tap] Web page loaded. This is page 2.
[swipe right] Click here for page 1. Visited. Link.
[double tap] Web page loaded. This is page 1.

Here it is again as a single-page application, using history manipulation to simulate actual page loads.

spa1.html

<!DOCTYPE html><html>
<body>This is page 1.
<a href='spa2.html'>Click here for page 2.</a></body>
<script src="switchPage.js"></script>
</html>

spa2.html

<!DOCTYPE html><html>
<body>This is page 2.
<a href='spa1.html'>Click here for page 1.</a></body>
<script src="switchPage.js"></script>
</html>

switchPage.js

console.log("load");
attachHandler();

function attachHandler() {
    document.querySelector('a').onclick = function(event) {
        event.preventDefault();
        history.pushState(null, null, this.href);
        drawPage();
    }
}

function drawPage() {
    var page, otherPage;
    // in real life, we'd do an AJAX call here or something
    if (/spa1.html$/.test(window.location.pathname)) {
        page = 1;
        otherPage = 2;
    } else {
        page = 2;
        otherPage = 1;
    }
    document.body.innerHTML = "This is page " + page +
        ".\n<a href='spa"+otherPage+".html'>Click here for page " +
        otherPage + ".</a>";
    attachHandler();
}

window.onpopstate = function() {
    drawPage();
};

(Note that this sample doesn't work from the filesystem; you have to load it from a webserver.)

This SPA example visually looks exactly like the simple multi-page example, except that page 2 "loads quicker" (because it's not really loading at all; it's all happening in JS).

But in Voiceover, it doesn't do the right thing.

Web page loaded. This is page 1.
[swipe right] Click here for page 2. Link.
[double tap] Click here for page 1. Visited. Link.
[The focus is on the link! swipe left] This is page 2.
[swipe right] Click here for page 1. Visited. Link.
[double tap] Web page loaded. This is page 1.

The focus is on the link, when it should be at the top of the page.

How do I tell Voiceover that the whole page has just updated and so the reader should resume reading from the top of the page?

Dan Fabulich
  • 37,506
  • 41
  • 139
  • 175

3 Answers3

1

Jorgen's answer, which is based on another StackOverflow thread got me on the right track.

The actual fix was not to wrap the entire page in a <div tabindex=-1> but instead to create a <span tabindex=-1> around just the first part ("This is page N") and focus that.

function drawPage() {
    var page, otherPage;
    // in real life, we'd do an AJAX call here or something
    if (/spa1.html$/.test(window.location.pathname)) {
        page = 1;
        otherPage = 2;
    } else {
        page = 2;
        otherPage = 1;
    }
    document.body.innerHTML = '<span tabindex="-1" id="page' + page + '">This is page ' + page +
        '.</span>\n<a href="spa'+otherPage+'.html">Click here for page ' +
        otherPage + '.</a>';

    document.getElementById('page' + page).focus();
    setTimeout(function() {
        document.getElementById('page' + page).blur();
    }, 0);
    attachHandler();
}

Note in this example we also blur the focus in a timeout; otherwise, non-screen-reader browsers will draw a visible blue focus rectangle around the span, which is not what we want. Blurring the focus doesn't affect the focus of the iOS VO reader.

Community
  • 1
  • 1
Dan Fabulich
  • 37,506
  • 41
  • 139
  • 175
1

Here's a further improvement, in response to Dan Fabulich's answer.

The question was about updating a page with pushState(), so I'll start with a semantic HTML structure containing a main content area to be updated.

<body>
    <header role="banner">
        <nav role="navigation">
            <a href="page1.html" onclick="updatePage(1, 'Page 1', this.href); return false;" aria-current="true">Page 1</a> |
            <a href="page2.html" onclick="updatePage(2, 'Page 2', this.href); return false;">Page 2</a>
        </nav>
    </header>
    <main role="main">
        <!-- This heading text is immediately after the nav, so it's a logical place to set focus. -->
        <h1 id="maintitle" tabindex="-1">Page 1</h1>
        <div id="maincontent">
<p>Lorem ipsum</p>
        </div>
    </main>
    <footer role="contentinfo">&copy; 2017</footer>
</body>

JavaScript:

function updatePage(pageNumber, pageTitle, pagePath) {
    var mainContent;
    if (pageNumber == 1) {
        mainContent = '<p>Lorem ipsum</p>';
    } else if (pageNumber == 2) {
        mainContent = '<p>Dolor sit amet</p>';
    } else {
        return;
    }
    document.getElementById('maincontent').innerHTML = mainContent;

    // TODO: Update the address bar with pushState()

    // TODO: Update the nav links, so only one link has aria-current="true"

    // Keep the browser title bar in sync with the content.
    document.title = pageTitle + ' - My Example Site';

    // Set focus on the visible heading, so screen readers will announce it.
    var mainTitleElement = document.getElementById('maintitle');
    mainTitleElement.innerHTML = pageTitle;
    mainTitleElement.focus(); // TODO: Add a 0ms timer, to ensure the DOM is ready.
}

About the focus outline (also known as focus ring):

  • Don't blur(). Screen readers can often recover, but they can't re-announce the current line, and it's a WCAG no-no.
  • Less bad than blurring is CSS span {outline:none}, but this is risky. I would only use it on static text with tabindex=-1 and it's not ideal because it's disorienting for sighted keyboard users.
  • The best solution I've seen is Alice Boxhall's input modality prollyfill.
0

I don't own a iPhone but a solution is provided in another stackoverflow thread. Wrap the content in a element with a tabindex of -1 and programmatically set the focus on that element.

function drawPage() {
    var page, otherPage;
    // in real life, we'd do an AJAX call here or something
    if (/spa1.html$/.test(window.location.pathname)) {
        page = 1;
        otherPage = 2;
    } else {
        page = 2;
        otherPage = 1;
    }
    document.body.innerHTML = '<div tabindex="-1" id="page' + page + '">This is page ' + page +
        '.\n<a href='spa'+otherPage+'.html'>Click here for page ' +
        otherPage + '.</a></div>';

    document.getElementById('page' + page).focus();
    attachHandler();
}
Community
  • 1
  • 1
Jorgen
  • 176
  • 1
  • 5
  • The code sample here has syntax errors (quoting issues in the `innerHTML` section), but even once those errors are fixed, it doesn't work. However, it did point me in a direction that _did_ find the fix, so if you edit your solution I'm prepared to award you the bounty. The actual fix was not to wrap the entire page in a `
    ` but instead to create a `` around just the first part ("This is page N") and focus that.
    – Dan Fabulich Jul 30 '16 at 05:33
  • Posted my own answer, but if you update your answer within the grace period I'll award you the bounty. – Dan Fabulich Aug 01 '16 at 18:18