Keyboard-accessible Google Maps
Introduction
Google Maps is a powerful, free tool for adding maps to a site. Unfortunately, out of the box, these maps are reliant on mouse interaction; therefore, they are completely inaccessible to those using only the keyboard for Web interaction. In this article, we'll examine the problem and, step by step, make our way toward a possible solution.
I'll assume that you're familiar with the basics of Google Maps, have obtained your API key , and implemented a map on your site. Now, let's say that you want to make your map a tad more accessible but without changing the look and feel of your existing map (maybe because your boss or designer insists on it)— a case of unobtrusive accessibility, if you will.
Added 15 June 2010: Since this article was written, version 3 of the API and version 2 of the Static Maps API no longer require API keys. Otherwise, the code is unchanged.
A basic map
To understand the problem of keyboard access, let's use Google's own example code as a starting point to display a map of Manchester, England.
We'll begin by using the lesser-known Google Static Maps API to place an image of our desired starting map on a page, wrapped in a simple container div
. In the absence of JavaScript, this provides users with a basic fallback:
<div id="map_canvas">
<img src="http://maps.google.com/staticmap?center=53.480998,-2.236748
&zoom=15&size=450x350&key=[YOUR API KEY]"
width="450" height="350"
alt="Map of Manchester, UK" />
</div>
View demo 1 to see this in action.
Next, we'll use the "regular" Google Maps API to replace the static contents of the container div
with a dynamic map, including large pan/zoom and map type controls. We'll also enable use of the scroll wheel and continuous zoom:
<script src="http://maps.google.com/maps?file=api&v=2&key=[YOUR API KEY]"
type="text/javascript"></script>
<script type="text/javascript">
function initialize() {
if (GBrowserIsCompatible()) {
var map = new GMap2(document.getElementById("map_canvas"));
map.setCenter(new GLatLng(53.480998, -2.236748), 15);
map.addControl(new GLargeMapControl());
map.addControl(new GMapTypeControl());
map.enableScrollWheelZoom();
map.enableContinuousZoom();
}
}
</script>
View demo 2 to see this in action.
At this point, we have a standard Google Map. It functions perfectly well when using your mouse and scroll wheel to move around, zoom or switch map types. However, if you try using the keyboard to Tab to the individual controls (apart from the Powered by Google and Terms & Use links), you're out of luck—there's nothing else that can receive focus via keyboard interaction.
Markup breakdown
Using a tool such as Opera Dragonfly (access it in the latest version of the Opera browser by activating the context menu and choosing Inspect Element) you can get a rough idea of what's going on—see Figure 1.
Figure 1: Opera Dragonfly, showing an extract of the DOM generated by the Google Maps API
Once Google's script is run, the content of <div id="map_canvas">
is replaced with a large amount of markup for the various map tiles and controls. Unless you're a masochist, I would not recommend trying to make sense of all that you will find there, but to explain the coming steps, it's worth pointing out some blocks of code (with areas of interest highlighted for slightly better readability).
<div class="gmnoprint" style="overflow: hidden; width: 59px; height: 256px; -moz-user-select: none; position: absolute; left: 7px; top: 7px;">
<div style="overflow: hidden; position: absolute; left: 0px; top: 0px; width: 59px; height: 226px;">
<div style="overflow: hidden; width: 59px; height: 354px;">
<img style="border: 0px none ; margin: 0px; padding: 0px; position: absolute; left: 0px; top: 0px; -moz-user-select: none; width: 59px; height: 458px;" src="http://maps.google.com/intl/en_ALL/mapfiles/mapcontrols2.png"/>
</div>
<div style="position: absolute; left: 20px; top: 0px; width: 18px; height: 18px; cursor: pointer;" title="Pan up" log="pan_up"/>
<div style="position: absolute; left: 0px; top: 20px; width: 18px; height: 18px; cursor: pointer;" title="Pan left" log="pan_lt"/>
<div style="position: absolute; left: 40px; top: 20px; width: 18px; height: 18px; cursor: pointer;" title="Pan right" log="pan_rt"/>
<div style="position: absolute; left: 20px; top: 40px; width: 18px; height: 18px; cursor: pointer;" title="Pan down" log="pan_down"/>
<div style="position: absolute; left: 20px; top: 20px; width: 18px; height: 18px; cursor: pointer;" title="Return to the last result" log="center_result"/>
<div style="position: absolute; left: 20px; top: 65px; width: 18px; height: 18px; cursor: pointer;" title="Zoom In" log="zi"/>
</div>
<div style="position: absolute; left: 0px; top: 226px; width: 59px; height: 354px; text-align: left;">
<div style="overflow: hidden; width: 59px; height: 30px; position: absolute;">
<img style="border: 0px none ; margin: 0px; padding: 0px; position: absolute; left: 0px; top: -354px; -moz-user-select: none; width: 59px; height: 458px;" src="http://maps.google.com/intl/en_ALL/mapfiles/mapcontrols2.png"/>
</div>
<div style="position: absolute; left: 20px; top: 11px; width: 18px; height: 18px; cursor: pointer;" title="Zoom Out" log="zo"/>
</div>
<div style="position: absolute; left: 19px; top: 86px; width: 22px; height: 150px; cursor: pointer;">
<div style="overflow: hidden; width: 22px; height: 14px; position: absolute; left: 0px; top: 8px; cursor: url(http://maps.google.com/intl/en_ALL/mapfiles/openhand.cur), default;" title="Drag to zoom">
<img style="border: 0px none ; margin: 0px; padding: 0px; position: absolute; left: 0px; top: -384px; -moz-user-select: none; width: 59px; height: 458px;" src="http://maps.google.com/intl/en_ALL/mapfiles/mapcontrols2.png"/>
</div>
</div>
</div>
This block is responsible for generating the pan/zoom controls. A single PNG image (see Figure 2) is used to give the visual appearance of the control buttons, while the actual clickable areas are transparent div
elements that are absolutely positioned on top of the image, coupled with some CSS to change the mouse to a hand icon (cursor: pointer
). The relevant onclick
behaviour (which triggers the actual map functions like panning and zooming) is added by Google's script after the div
s have been generated.
Figure 2: The single PNG image used to display the pan/zoom controls
Similarly, the map type controls can be found in the following block of markup:
<div class="gmnoprint" style="-moz-user-select: none; position: absolute; right: 7px; top: 7px; color: black; font-family: Arial,sans-serif; font-size: small; width: 200px; height: 19px;">
<div id="amtc_option_0" style="border: 1px solid black; position: absolute; background-color: white; text-align: center; width: 5em; cursor: pointer; right: 10.2em;" title="Show street map">
<div style="border-style: solid; border-color: rgb(52, 86, 132) rgb(108, 157, 223) rgb(108, 157, 223) rgb(52, 86, 132); border-width: 1px; font-size: 12px; font-weight: bold;">Map</div>
</div>
<div id="amtc_option_1" style="border: 1px solid black; position: absolute; background-color: white; text-align: center; width: 5em; cursor: pointer; right: 5.1em;" title="Show satellite imagery">
<div style="border-style: solid; border-color: white rgb(176, 176, 176) rgb(176, 176, 176) white; border-width: 1px; font-size: 12px;">Satellite</div>
</div>
<div id="amtc_option_2" style="border: 1px solid black; position: absolute; background-color: white; text-align: center; width: 5em; cursor: pointer; right: 0em;" title="Show imagery with street names">
<div style="border-style: solid; border-color: white rgb(176, 176, 176) rgb(176, 176, 176) white; border-width: 1px; font-size: 12px;">Hybrid</div>
</div>
</div>
Looking at the code that Google generates for the various controls, it quickly becomes apparent why they are not keyboard accessible. In HTML, the only elements that can receive keyboard focus (or, to put it another way, the only elements that get included in the browser/user agent tab cycle) are links, image map areas, and form elements. As used here, div
s cannot receive focus, so a keyboard user doesn't get a chance to navigate or activate these controls when using the Tab key.
It's worth noting that Opera's keyboard navigation scheme is different from that of other browsers. In normal operation, CTRL + ↑ and CTRL + ↓cycles between links, while TAB is used to cycle between form elements—neither of any use to navigate the standard Google maps. However, Opera also has a feature it calls spatial navigation: Shift + ←,Shift + ↑,Shift + → and Shift + ↓ allow you to move directly to links and form elements based on their position in the layout of the page. But that's not all! Spatial navigation includes heuristics that allow you to focus on arbitrary elements that have an onclick
behaviour attached to them, meaning that the default Google maps are in fact keyboard accessible in Opera without any additional work with this navigation scheme.
For the sake of all other browsers, though, let's look at how to make these maps more keyboard friendly.
Google's keyboard handler?
Looking through Google's API documentation, we come across the initially promising GKeyboardHandler class, which adds keyboard bindings to a map. Instantiating this class with a simple
new GKeyboardHandler(map);
lets users navigate the map with their cursor keys, Home and End for small movements, Page Up and Page Down for large movements, and the + and - keys to control zooming.
View demo 3 to see this in action.
However, the implementation of this class leaves a lot to be desired. By default, keyboard access is only possible after the map has first received at least one mouse click for activation, leaving us back where we started.
There is a way around this: using Google's GEvent functions it's possible to simulate such a click event after the GKeyboardHandler
has been instantiated.
var mapContainer = document.getElementById("map_canvas");
GEvent.trigger(document, "click",
{srcElement: mapContainer, target: mapContainer, nodeType: 1}
);
View demo 4 to see this in action.
With this code, as soon as the document is loaded and the map has been created, the keyboard handler's key bindings are active. The main problem is that now these keys are bound to the map throughout the document, not just when you want to control the map itself. These key bindings can also override any browser or assistive-technology key bindings. For instance, in Opera it's now not possible to use the cursor keys to move around the document or to use + and - to zoom the entire page. Strangely, the key bindings also get lost when activating in-page links, requiring another explicit click on the map to take effect again. And lastly, these keyboard controls are non-standard—a keyboard user would need to be informed about these new key bindings with some additional explanatory text.
To conclude this section, I'll keep the call to GKeyboardHandler
in our code—for those users that primarily navigate with a mouse but on occasion like to use keyboard controls—but do away with the flawed idea of binding the keys on page load. For true keyboard access, I'll need to come up with a different strategy.
Retrofitting correct markup
As already noted, the only elements that normally receive focus in keyboard navigation are links, image map areas, and form elements. So, in order to make Google maps more keyboard accessible, we want to replace the current "fake" controls (div
s with associated onclick
behaviour) with one of these elements.
In an ideal world, this solution should be implemented right at the source. It certainly wouldn't take Google long to make some subtle modifications to their Maps API code. Unfortunately, interest in this problem seems to be quite low. The only real mention of poor keyboard support seems to be the single discussion thread on the official Google Maps API group that I started about a year ago.
Short of "forking" Google's code and writing our own version of the Maps API—and all the headaches associated with reverse-engineering the original JavaScript and keeping our version in sync with the official codebase—the only option is to use the standard API and then retrofit the generated markup, injecting the most "semantically appropriate" keyboard-accessible HTML elements into those inaccessible controls.
The injected markup
Your first instinct may be to simply plump for a common a
element. However, I'd argue that in this case button
would be a better choice—links should be used only for navigation, not to effect changes within the current page:
Push buttons have no default behaviour. Each push button may have client-side scripts associated with the element's event attributes. When an event occurs (e.g., the user presses the button, releases it, etc.), the associated script is triggered.
--HTML 4.01 specification — Form Control Types
So, in simple terms, we'll create a Google map as before and then iterate over all the "fake" controls, creating a new button
for each one and appending it as a new child element of the div
. The initial pseudo-code for this is as follows:
var button;
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if (/* one of the control DIV elements we're interested in */) {
button = document.createElement("BUTTON");
divs[i].appendChild(button);
}
}
As with other form controls, we should provide an explicit label. In the case of button
, this is usually achieved by setting the element's value
attribute. As all the control div
s we're interested in also have a descriptive title
attribute, we'll hijack that to act as our label:
var button;
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if (/* one of the control DIV elements we're interested in */) {
button = document.createElement("BUTTON");
button.setAttribute('value',divs[i].getAttribute('title'));
divs[i].appendChild(button);
}
}
Now, looking at the markup generated by Google maps, we need to determine the appropriate hooks that will let us select the right control div
s.
Direction and zoom controls
Unfortunately, the direction and zoom controls don't have any distinguishing id
or class
attributes. They do, however, have a non-standard log
attribute that we can use to filter them out from the rest of the div
s:
var button;
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if (divs[i].hasAttribute('log')) {
button = document.createElement("BUTTON");
button.setAttribute('value',divs[i].getAttribute('title'));
divs[i].appendChild(button);
}
}
Combining this with our previous code, we get:
function initialize() {
if (GBrowserIsCompatible()) {
var map = new GMap2(document.getElementById("map_canvas"));
map.setCenter(new GLatLng(53.480998, -2.236748), 15);
map.addControl(new GLargeMapControl());
map.addControl(new GMapTypeControl());
map.enableScrollWheelZoom();
map.enableContinuousZoom();
new GKeyboardHandler(map);
var button;
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if (divs[i].hasAttribute('log')) {
button = document.createElement("BUTTON");
button.setAttribute('value',divs[i].getAttribute('title'));
divs[i].appendChild(button);
}
}
}
}
View demo 5 to see this in action.
Here, we come across our first JavaScript error in Internet Explorer. Although hasAttribute
is the correct function to use when checking for the presence of an attribute, IE doesn't understand it. Grudgingly, we'll change this to getAttribute
—modern browsers also return true or false depending on the presence of this attribute, just as with hasAttribute
.
Regardless of this change, nothing seems to happen in any browser. The reason for this is that our new chunk of code is executed before the map has finished loading, and the controls we're trying to alter have been generated.
So, let's refactor the code into a separate function that can be called when the Google Maps API load
event has been triggered.
This event is fired when the map setup is complete, and
isLoaded()
would return true. This means position, zoom, and map type are all initialized, but tile images may still be loading.
function initialize() {
if (GBrowserIsCompatible()) {
var map = new GMap2(document.getElementById("map_canvas"));
GEvent.addDomListener(map, "load", GKeyboardPatch(map));
map.setCenter(new GLatLng(53.480998, -2.236748), 15);
map.addControl(new GLargeMapControl());
map.addControl(new GMapTypeControl());
map.enableScrollWheelZoom();
map.enableContinuousZoom();
new GKeyboardHandler(map);
}
}
function GKeyboardPatch(map) {
var button;
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if (divs[i].getAttribute('log')) {
button = document.createElement("BUTTON");
button.setAttribute('value',divs[i].getAttribute('title'));
divs[i].appendChild(button);
}
}
}
View demo 6 to see this in action.
Conceptually, this is sound, but now we're getting an undecipherable JavaScript error from the Maps API itself—
. After much debugging and cursing, it appears that the this.Wf
is undefinedload
event actually fires after the map itself has been initialised (as per the documentation), but before the controls are generated.
Now, we could write some very convoluted script that keeps checking at regular intervals to see if the controls have been generated before calling the GKeyboardPatch
function. I'll gladly leave this as a task for the reader, opting instead for a quick kludge: once the load
event is fired, we'll give the API three seconds (based on a bit of trial and error) to sort itself out before our function call is made. This is certainly not the cleanest way to do it, and your mileage may vary if you're doing further things with the map (such as putting in additional overlays).
GEvent.addDomListener(map, "load", function() {
setTimeout('GKeyboardPatch(map);',3000);
});
View demo 7 to see this in action.
This gets rid of the nasty Maps API error, but brings to light another problem. Once we use setTimeout
, our function call GKeyboardPatch(map)
is executed in the document's global context, and since the map
variable was defined inside the initialize()
function, it's undefined at global level.
Again, for expediency, we'll take the easy way out and simply define map
in the global context. There are better, far more robust ways of working around this problem by further refactoring the code, but for the sake of this article, let's go with this:
var map; // a small kludge, polluting the document's namespace
function initialize() {
...
}
function GKeyboardPatch(map) {
...
}
View demo 8 to see this in action.
At long last, some actual results! A few seconds after the map is initialised, we see empty button
elements being placed over the controls, but not in IE—they're there, but not visible! More on that in a moment.
Because the button
s we generated only have a value
attribute, and no actual content, most browsers will display them as small slivers, while Safari defaults to its idiosyncratic "round and shiny" rendering, as seen in Figure 3.
Figure 3: Safari's 'round and shiny' rendering of the empty button elements.
Using default keyboard controls, we can now Tab to the individual controls and activate the direction and zoom functions.
Doing it in style
At this point, we'll start looking at the styling of these new button
elements. We really just want them to be invisible, save for the browser's default outline once they receive focus. Working around various browser quirks and inconsistencies, I ended up with the following series of style rules (which I won't explain in any detail here—if you feel brave and have a few hours to spare, try and omit or change a few and see how that affects individual user agents):
width: 100%;
height: 100%;
padding: 2px;
margin: 2px;
background: transparent;
border-width: 0px;
border-style: solid;
cursor: pointer;
overflow: hidden;
text-indent: -100em;
position: absolute;
top: -2px;
left: -2px;
We could define these rules in a separate CSS style block or external style sheet, but to make this code more or less self-contained, we'll inject the styles directly in the style
attribute of each generated button
(since that's what the Maps API already does anyway).
function GKeyboardPatch(map) {
var button, button_style =
'width:100%;height:100%;padding:2px;margin:2px; \
background:transparent ;border-width:0px;border-style:solid; \
cursor: pointer;overflow:hidden ;text-indent:-100em; \
position:absolute ;top:-2px;left:-2px;';
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if (divs[i].getAttribute('log')) {
button = document.createElement("BUTTON");
title = divs[i].getAttribute('title');
button.setAttribute('value',divs[i].getAttribute('title'));
button.setAttribute('style',button_style);
divs[i].appendChild(button);
}
}
}
View demo 9 to see this in action.
This now seems to work reliably across all browsers, except for IE. First, let's try and get an obvious problem out of the way: IE doesn't understand setAttribute('style',...)
. We'll modify the code to cater for all sane (compliant) browsers as well as for IE.
// proper W3C DOM method for styling
button.setAttribute('style',button_style);
// ...and now to make it work in IE
button.style.cssText = button_style;
Using the Internet Explorer Developer Toolbar (see Figure 4), we can see that the button styles are indeed applied now. However—and this took another lengthy round of cursing and debugging to discover—the Google Maps API also sends additional styles for the "fake" div
controls to IE. Specifically, it sets background-color:white
and the proprietary filter: alpha(opacity=1)
.
Figure 4: Internet Explorer Developer Toolbar, showing the computed CSS properties of one of the control divs
Let's override these two styles for each div
that we iterate through as well. Changing the filter
is not a problem, but simply setting the background-color
to transparent renders the controls unusable in IE for some reason. As a convoluted work-around, I've set the background
to a transparent PNG instead, and again, to make this code self-contained I'm going to borrow one that Google Maps itself already uses elsewhere.
for (var i = 0; i < divs.length; i++) {
if (divs[i].getAttribute('log')) {
button = document.createElement("BUTTON");
button.setAttribute('value',divs[i].getAttribute('title'));
// proper W3C DOM method for styling
button.setAttribute('style',button_style);
// ...and now to make it work in IE
button.style.cssText = button_style;
divs[i].appendChild(button);
// override the IE opacity filter that Google annoyingly sets
divs[i].style.filter = '';
// should really set to 'transparent'
divs[i].style.background =
'url(http://www.google.com/intl/en_ALL/mapfiles/transparent.png)';
}
}
View demo 10 to see this in action.
After some head-scratching and serious markup detective work, we've now got the direction and zoom controls working and looking as they should. Let's move on to the map style controls.
Map style controls
As before, we need to find the appropriate markup hooks that let us isolate and select the map style controls. Referring back to the generated markup, we see that these div
elements do in fact have id
attributes (amtc_option_0, amtc_option_1, and so forth, depending on which map type options you've implemented). There's only one other type of div
in Google's markup that has an id
: if you've chosen map.enableContinuousZoom()
—as we did, simply because it looks nice in combination with map.enableScrollWheelZoom()
—you'll find a <div id="map_magnifyingglass" ...>
.
So, on top of injecting the button
and making the necessary style changes for all div
s with a log
attribute, we want to do the same for any div
that has an id
, but whose id
isn't map_magnifyingglass. With some judicious use of brackets (for clarity) and boolean logic operators, I've ended up with:
for (var i = 0; i < divs.length; i++) {
if ( divs[i].getAttribute('log') ||
( divs[i].getAttribute('id') &&
(divs[i].getAttribute('id')!='map_magnifyingglass')
)
) {
...
}
}
View demo 11 to see this in action.
The end result
Now, we're on the home stretch. The previous code worked perfectly, but with one small glitch: the styling we applied to the div
s to get around IE's additional style rules is now making the map type controls transparent as well. One last modification and we're good to go ... here's the complete final code:
var map; // a small kludge, polluting the document's namespace
function initialize() {
if (GBrowserIsCompatible()) {
map = new GMap2(document.getElementById("map_canvas"));
GEvent.addDomListener(map, "load", function() {
setTimeout('GKeyboardPatch(map);',3000);
});
map.setCenter(new GLatLng(53.480998, -2.236748), 15);
map.addControl(new GLargeMapControl());
map.addControl(new GMapTypeControl());
map.enableScrollWheelZoom();
map.enableContinuousZoom();
new GKeyboardHandler(map);
}
}
function GKeyboardPatch(map) {
var button, button_style =
'width:100%;height:100%;padding:2px;margin:2px; \
background:transparent ; border-width:0px;border-style:solid; \
cursor: pointer;overflow:hidden ;text-indent:-100em; \
position:absolute ;top:-2px;left:-2px;';
var divs = map.getContainer().getElementsByTagName('DIV');
for (var i = 0; i < divs.length; i++) {
if ( divs[i].getAttribute('log') ||
( divs[i].getAttribute('id') &&
(divs[i].getAttribute('id')!='map_magnifyingglass')
)
) {
button = document.createElement("BUTTON");
button.setAttribute('value',divs[i].getAttribute('title'));
// proper W3C DOM method for styling
button.setAttribute('style',button_style);
// ...and now to make it work in IE
button.style.cssText = button_style;
divs[i].appendChild(button);
if (divs[i].getAttribute('log')) { // only control buttons
// override the IE opacity filter that Google annoyingly sets
divs[i].style.filter = '';
// should really set to 'transparent'
divs[i].style.background =
'url(http://www.google.com/intl/en_ALL/mapfiles/transparent.png)';
}
}
}
}
View demo 12 to see the final code in action.
Summary
It's been an arduous journey; let's recap: we've seen that the default Google Maps aren't keyboard accessible (as they don't use appropriate elements that can receive keyboard focus by default), that Google's GKeyboardHandler
doesn't provide a sufficient solution, and how—through some analysis of Google's generated code and a lot of fairly complex JavaScript—we can put a touch of accessibility back into these maps.
The final script we've ended up with is by no means perfect. In fact, I'd encourage readers to rip it apart and come up with something far more elegant. But what I'd like people to consider here is the general approach taken. With a lot of third-party applications, such as Google Maps, we don't have direct control over the often quite horrific markup that is being flung onto our pages. It would be great if any major issues—particularly with regards to accessibility—were solved at the source. It certainly wouldn't take much for Google to improve their API and instantly make their maps more accessible. But, in the meantime, in order to overcome these sorts of shortcomings and cater to our users, we may have to resort to retrofitting the code we're given, post-processing it with some JavaScript DOM manipulation. Consider it progressive enhancement.
In this article, I've assumed that you want to improve access to Google Maps without compromising the application's default look and feel—perhaps because you're simply adding accessibility "under the radar", in an unobtrusive fashion, because your boss or designer is really fond of the way the maps are out of the box. However, if you have more flexibility and are free to change the visual appearance of the map controls, my next article will show you a cleaner way of making your maps more keyboard-accessible through the use of custom controls. Stay tuned!
This article is licensed under a Creative Commons Attribution, Non Commercial - Share Alike 2.5 license.
Comments
The forum archive of this article is still available on My Opera.