Dan Fabulich Dan Fabulich - 4 months ago 12
iOS Question

Simulate Voiceover page load in single-page pushState web application

I'm working on a single-page application (SPA) where we're simulating a multi-page application with HTML 5

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

Answer

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 div, which is not what we want. Blurring the focus doesn't affect the focus of the iOS VO reader.