Yaogang Lian

Accessibility in a Client-side Webapp

Recently I have been rewriting a couple of my iOS apps with Muffin. The UI looks quite nice and the performance is quite good. However, I still need to figure out how to make the webapp work nicely with accessibility tools, esp. iOS’s VoiceOver.





The good news is that VoiceOver can already pick up most texts in the webview. However, the user experience is far from ideal. For example, in the screenshot above, the top left “+” button is read aloud simply as “link”. Since the “+” button is implemented as <a class='add' href='#'></a>, VoiceOver doesn’t know it should be treated as a button, not to mention an “add” button. A few more problem areas are highlighted in the screenshot.

Luckily all these issues can be fixed very easily. But information on this subject is scarce on the Internet and most articles I could find are quite old. Hopefully this post will make the situation a bit better.

Buttons

In a client-side webapp, you might have a lot of buttons implemented as links or divs. For example, the “+” button in the screenshot is implemented as:

1
2
3
<div class="left-bar-button">
<a class='add' href='#'></a>
</div>

To tell VoiceOver that this should be treated as a button, all we need to do is to use the WAI-ARIA role and add a aria-label:

1
2
3
<div class="left-bar-button" role="button" aria-label="add">
<a class='add' href='#'></a>
</div>

Now VoiceOver happily reads the button aloud as “Add Button”. Bingo!

Tabs

There is a tab bar at the bottom of the screenshot, which is implemented like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<ul>
<li data-tab="favs" class="active">
<div class="tab-icon icon-fav"></div>
<div class="tab-title">Favourites</div>
</li>
<li data-tab="nearby">
<div class="tab-icon icon-nearby"></div>
<div class="tab-title">Nearby</div>
</li>
<li data-tab="routes">
<div class="tab-icon icon-route"></div>
<div class="tab-title">Routes</div>
</li>
<li data-tab="settings">
<div class="tab-icon icon-gear"></div>
<div class="tab-title">Settings</div>
</li>
</ul>

VoiceOver doesn’t know these are tabs, so it simply reads out the texts. We can do a better job by adding a role to each list item.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<ul>
<li data-tab="favs" class="active" role="tab">
<div class="tab-icon icon-fav"></div>
<div class="tab-title">Favourites</div>
</li>
<li data-tab="nearby" role="tab">
<div class="tab-icon icon-nearby"></div>
<div class="tab-title">Nearby</div>
</li>
<li data-tab="routes" role="tab">
<div class="tab-icon icon-route"></div>
<div class="tab-title">Routes</div>
</li>
<li data-tab="settings" role="tab">
<div class="tab-icon icon-gear"></div>
<div class="tab-title">Settings</div>
</li>
</ul>

There is still one more issue. When the user changes tabs, VoiceOver doesn’t know which tab is active. Let’s fixed that in JavaScript.

1
2
3
4
selectTab: (tab) ->
$tab = @$("li[data-tab='#{tab}']")
$tab.siblings().removeClass('active').attr('aria-selected', 'false')
$tab.addClass('active').attr('aria-selected', 'true')

When the user changes tab, we set the “aria-selected” attribute to “true” on the active tab. Now VoiceOver reads the tab as “Selected, Favourites, Tab, list start, 1 of 4”. Awesome! That’s much more informative.

TableViewCell

In the native iOS app, I have set up accessibility labels for each UITableViewCell, so that the cell is presented as a whole to the screen reader. For example, in the image above, the selected BTStopCell is read aloud as “Hotel Grand Pacific, Bus stop #01012”. This helps the visually-impaired to quickly navigate through the list of bus stops.

Adding accessibility labels in the native iOS code is straightforward. This is all it takes:

1
2
3
4
5
6
7
@implementation BTStopCell
- (NSString *)accessibilityLabel
{
NSString *distance = [HAUtils formattedStringForDistance:stop.distance];
return [NSString stringWithFormat:@"%@, Bus stop #%@, %@", stop.stopName, stop.stopCode, distance];
}

Now that I have rewritten the native iOS app as a webapp, I need a similar way to add accessibility labels to the stop cells. It turns out to be just as easy.

This is the HTML for the stop cell:

1
2
3
4
5
6
7
8
<li class="stop-cell" data-id="<%= stop.id %>">
<div class="stop-info">
<div class="stop-name"><%= stop.name %></div>
<div class="stop-number">Bus stop #<%= stop.id %></div>
</div>
<div class="distance"><%= distance %></div>
<div class="disclosure-indicator"></div>
</li>

To tell VoiceOver to treat the cell as a whole, we just need to specify a role on the list item and set its “aria-label”.

1
2
3
4
5
6
7
8
<li class="stop-cell" data-id="<%= stop.id %>" role="text" aria-label="<%= 'Bus stop #' + stop.id + ' ' + stop.name %>">
<div class="stop-info">
<div class="stop-name"><%= stop.name %></div>
<div class="stop-number">Bus stop #<%= stop.id %></div>
</div>
<div class="distance"><%= distance %></div>
<div class="disclosure-indicator"></div>
</li>

Now VoiceOver reads the whole cell as “Bus stop #01012, Hotel Grand Pacific”, instead of selecting each text in the cell separately.

Hide UI Elements

While playing with VoiceOver, I found that sometimes it picks up a big rectangular image, although I couldn’t see anything there on the screen. It turns out it’s picking up map tiles from a Google Map in a lower layer (the map is used elsewhere in the app but always kept in the DOM for performance reasons). Let’s fix that.

All we need to do is to set the “aria-hidden” attribute to “true” on the map container:

1
<div class="map" aria-hidden="true"></div>

Alert

Sometimes we need to show a toast or an alert message to the user, like in the screenshot below. How can we make this alert message accessible to the visually-impaired?





Quite simple, actually. We just need to add a role to the toast.

1
<div id="toast" role="alert"></div>

There are a few things to keep in mind, though. You must never set display: none on the toast element, otherwise it won’t work. I was using fadeIn() and fadeOut() on the toast element before, and it took me several hours to figure out why it’s not working. A simple workaround is to use fadeTo() instead.

1
2
3
4
5
6
7
showToast: (msg) ->
clearTimeout(@toastTimer) if @toastTimer?
$toast = @$toast
$toast.stop(true, true).text(msg).fadeTo('slow', 1.0)
@toastTimer = setTimeout ->
$toast.fadeTo 'slow', 0
, 2000

Sometimes, VoiceOver swallows the alert message because the interface has changed and it has to read out new interface elements. In those cases I have found that adding a small delay (100ms) before showing the alert can help.

More…

There are many more things you can do with WAI-ARIA. Check out the spec for details.

Yaogang Lian

An iOS, Mac and web developer. Focusing on building productivity and educational apps.