Keyboard accessibility in web applications: part 1
Table of contents
Introduction
In my previous article I showed you how to start developing a browser-based painting application. In this three-part article we will follow on from that, focussing on making our application keyboard accessible. Along the way, you should pick up some good ideas as to how to improve the accessibility of your own web applications.
You should allow the users of your application to access provided functionality by the keyboard as well as the mouse, to benefit users who cannot use a mouse or trackpad, perhaps due to some kind of motility impairment, or because their chosen browsing device does not have one.
We will take a look at how to handle keyboard-related events in the painting application, globally and per drawing tool. Additionally, we will implement a way to move the pointer inside the drawing canvas using keyboard shortcuts.
The process of making your web application accessible to its users does not just require adding keyboard shortcuts. In addition, your markup and the application DOM need to be semantic, and you might need to setup additional WAI-ARIA attributes, such that the application can be used with screen readers. Making an application accessible will vary much from case to case - there is no one-size-fits-all solution.
To make it easier to follow along with the code walkthrough presented below, download the full code examples and follow along with it as you read the article.
Getting started
We will begin right from the finished example
provided in the previous
article (refer to example5.html
in the code download).
Before we dive deep into coding the keyboard shortcuts we need to first organize the code better, to allow the web application to grow more easily.
First we will take the drawing tools out of the main code, and move them all inside a new tools.js file (see app1/tools.js inside the code download). Then we will reorganize the code such that all the tool objects are inside a single namespace object, PaintTools. Here's a snippet from the code:
/**
* Holds the implementation of each drawing tool.
*/
var PaintTools = {};
/**
* @class The drawing pencil.
*
* @param {Painter} app Reference to the main paint application object.
*/
PaintTools.pencil = function (app) {
var _self = this,
context = app.buffer.context,
update = app.layerUpdate;
/**
* Tells if the user has started the drawing operation or not.
*
* @private
* @type Boolean
*/
var started = false;
/**
* Initialize the drawing operation.
*
* @param {Event} ev The DOM Event object.
*/
this.mousedown = function (ev) { ... };
/**
* Perform the drawing operation, while the user moves the mouse.
*
* @param {Event} ev The DOM Event object.
*/
this.mousemove = function (ev) { ... };
/**
* End the drawing operation, once the user releases the mouse button.
*
* @param {Event} ev The DOM Event object.
*/
this.mouseup = function (ev) { ... };
};
/**
* @class The rectangle tool.
*
* @param {Painter} app Reference to the main paint application object.
*/
PaintTools.rect = function (app) { ... };
// ...
The code above has comments in jsdoc comment format. It is recommended you add comments to your code describing any API, so that it is easier for others to read and extend your code. With jsdoc-toolkit you can generate complete documentation based on the comments you write in the source code files.
Additional changes are required in the main code (index.js - see also app1/index.js
in the code download) to enable us to make use of the new
PaintTools object. Here's the relevant code snippet:
/**
* @class The paint tool application object.
*/
function Painter () {
var _self = this;
/**
* Holds the buffer canvas and context references.
* @type Object
*/
this.buffer = {canvas: null, context: null};
/**
* Holds the current layer ID, canvas and context references.
* @type Object
*/
this.layer = {id: null, canvas: null, context: null};
/**
* The instance of the active tool object.
*
* @type Object
* @see Painter#tool_default holds the ID of the tool which is activated when
* the application loads.
* @see PaintTools The object which holds the implementation of each drawing
* tool.
*/
this.tool = null;
/**
* The default tool ID.
*
* @type String
* @see PaintTools The object which holds the implementation of each drawing
* tool.
*/
this.tool_default = 'line';
/**
* Initialize the paint application.
* @private
*/
function init () { ... };
/**
* The Canvas event handler.
*
* This method determines the mouse position relative to the canvas
* element, after which it invokes the method of the currently active tool
* having the same name as the current event type. For example, for the
* mousedown
event the tool.mousedown() method is
* invoked.
*
* The mouse coordinates are added to the ev DOM Event object:
* ev.x_ and ev.y_.
*
* @private
* @param {Event} ev The DOM Event object.
*/
function ev_canvas (ev) { ... };
/**
* The event handler for any changes made to the tool selector.
* @private
*/
function ev_tool_change () { ... };
/**
* This method draws the buffer canvas on top of the current image layer,
* after which the buffer is cleared. This function is called each time when
* the user completes a drawing operation.
*/
this.layerUpdate = function () { ... };
init();
};
if(window.addEventListener) {
window.addEventListener('load', function () {
if (window.Painter) {
// Create a Painter object instance.
window.PainterInstance = new Painter();
}
}, false); }
Following the same style as in the tools file, we add jsdoc comments and
define a Painter object, which holds the main code of the web
application. Once the page loads, a new instance of the Painter
object is created.
For internationalization, it is better to keep all language-related strings in separate files, for each supported language. For this purpose, we now have a simple lang/en.js file. Currently there are only a few strings, but many more will be added in the future. You might want to consider the use of a JSON file for storing language strings, instead of JavaScript. This is left as an exercise for those interested in further modifying these examples.
Please take a look at the updated code (app1 directory), and the documentation (doc directory) in the code download. The documentation is generated generated using jsdoc-toolkit; you will see it is very much the same as what we had in the previous article.
Tool activation
In order to add keyboard shortcuts there are several important aspects you need to take into consideration. For one, you need to implement a cross-browser compatibility layer. Unfortunately, the DOM keyboard events have important implementation differences between browsers. Thus, you need to provide for any such differences in your code. Additionally, you need to structure your code and API to make sure you can easily add new keyboard shortcuts to any action you want.
To tackle cross-browser compatibility you might want to check out an open-source JavaScript library such as jQuery or Dojo - you could write code yourself, but there is no point reinventing the wheel. For the sake of simplicity, here we will reuse a small part of libmacrame: the keyboard event handling mechanism.
For global keyboard shortcuts we only need to add an event listener to
the window object, for the keydown
event. The event
handler determines the key combination generated by the event and checks the
list of defined keyboard shortcuts to find which operation needs to be
performed. Additionally, we need a configuration file to store the keyboard
shortcuts and the associated tool IDs.
Here's a snippet from the updated
init
() function (see also inside the app2 folder in the code download):
function Painter () {
// ...
/**
* Holds references to important DOM elements.
*
* @private
* @type Object
*/
this.elems = {};
/**
* Holds the keyboard event listener object.
*
* @private
* @type lib.dom.KeyboardEventListener
* @see lib.dom.KeyboardEventListener The class dealing with the cross-browser
* differences in the DOM keyboard events.
*/
var kbListener_;
// ...
function init () {
// ...
// Get the tools drop-down.
_self.elems.tool_select = document.getElementById('tool');
if (!_self.elems.tool_select) {
alert(lang.errorToolSelect);
return;
}
_self.elems.tool_select.addEventListener('change', ev_tool_change, false);
// Activate the default tool.
_self.toolActivate(PainterConfig.tool_default);
// ...
// Add the keydown event listener.
kbListener_ = new lib.dom.KeyboardEventListener(window,
{keydown: ev_keydown});
};
// ...
};
The initialization function now adds the keydown
event
listener using the compatibility layer provided by the minimal JavaScript
library (see the new lib.js file - see the app2 directory in code download). The event
handler we provide makes the keyboard shortcuts work in the paint
application.
Another rather minor detail is that we now need to store a reference to
the tool drop-down element in elems, so we can change the
selected value when another tool is activated using the new
toolActivate()
method:
this.toolActivate = function (id) {
if (!id) {
return false;
}
// Find the tool object.
var tool = PaintTools[id];
if (!tool) {
return false;
}
// Check if the current tool is the same as the desired one.
if (_self.tool && _self.tool instanceof tool) {
return true;
}
// Construct the new tool object.
var tool_obj = new tool(_self);
if (!tool_obj) {
alert(lang.errorToolActivate);
return false;
}
_self.tool = tool_obj;
// Update the tool drop-down.
_self.elems.tool_select.value = id;
return true;
};
The toolActivate()
method does the same as the
ev_tool_change()
method did, but it is no longer relying on the
existence of the tools drop-down element. The id parameter takes
a string - the id
of the drawing tool - to activate it. The
drawing tool must be defined in the global PaintTools object (see
tools.js from application 2 inside the app2 directory in the code download).
Here's the updated ev_tool_change()
function:
/**
* The event handler for any changes made to the tool selector.
*
* @private
* @see Painter#toolActivate The method which does the actual drawing tool
* activation.
*/
function ev_tool_change () {
_self.toolActivate(this.value);
};
It is a piece of cake now - we just call the toolActivate()
method with the input value.
Next, to make everything work we define the keydown
event
handler:
function ev_keydown (ev) {
if (!ev || !ev.key_) {
return;
}
// Do not continue if the event target is some form input.
if (ev.target && ev.target.nodeName) {
switch (ev.target.nodeName.toLowerCase()) {
case 'input':
case 'select':
return;
}
}
// Determine the key ID.
var i, kid = '',
kmods = {altKey: 'Alt', ctrlKey: 'Control', shiftKey: 'Shift'};
for (i in kmods) {
if (ev[i] && ev.key_ != kmods[i]) {
kid += kmods[i] + ' ';
}
}
kid += ev.key_;
// If there's no event handler within active tool, or if the event handler
// does otherwise return false, then continue with the global keyboard
// shortcuts.
var gkey = PainterConfig.keys[kid];
if (!gkey) {
return;
}
// Activate the tool associated with the current keyboard shortcut.
if (gkey.tool) {
_self.toolActivate(gkey.tool);
}
if (ev.preventDefault) {
ev.preventDefault();
}
};
In the keydown
event handler we make use of the new minimal
JavaScript library (see the new lib.js file). The
lib.dom.KeyboardEventListener
class is used to determine the
key generated by the event. It will always provide the key_ event
property to our event handler. This makes it easy to check which key was
generated via a user interaction. We will go into details about this class in
one of the sections ahead, but for the sake of simplicity we are now focusing on the actual web application.
We next generate the "key ID" - which includes the name of the modifiers - to make it easier to write down the keyboard shortcuts in the configuration file.
The configuration file
To store the keyboard shortcuts we could simply define a new object in the main file. However, it is best that we keep the code separated, therefore we create a new config.js file. You might want to use JSON for your configuration.
Here's the code:
/**
* @namespace Holds all the configuration for the paint application.
*/
var PainterConfig = {
/**
* The default tool ID.
*
* @type String
* @see PaintTools The object holding all the drawing tools.
*/
tool_default: 'line',
/**
* Keyboard shortcuts associated to drawing tools.
*
* @type Object
* @see PaintTools The object holding all the drawing tools.
*/
keys: {
L: { tool: 'line' },
P: { tool: 'pencil' },
R: { tool: 'rect' }
}
};
For now, it is quite small, but in the future you will need a lot more configuration options in this file.
In the PainterConfig.keys object we can setup the keyboard
shortcuts. If the keyboard combination is found in this list, then the
associated tool is activated by the keydown
event handler.
To wrap it all up, you just need to update the index.html markup to include the new scripts: config.js and lib.js.
That's about all you need to do. Now you can activate any drawing tool using keyboard shortcuts.
Try the updated application in the code download. Also, make sure to take a look at the app2 documentation in the download too.
Keyboard shortcuts in drawing tools
The next logical step is to extend what we have in order to allow any drawing tool to have its own keyboard shortcuts. For example, you might want to be able to press Escape to cancel a drawing operation, or when you code a selection tool, you might like the Delete key to clear the selected pixels.
Luckily, there is only a small amount of work remaining to be done to
achieve our goal. You need to update the ev_canvas()
function
to return the result of the current tool event handler, or
false
if no event handler was executed. Here is the updated
function:
function ev_canvas (ev) {
if (!ev.type || !_self.tool) {
return false;
}
if (typeof ev.layerX != 'undefined') { // Firefox
ev.x_ = ev.layerX;
ev.y_ = ev.layerY;
} else if (typeof ev.offsetX != 'undefined') { // Opera
ev.x_ = ev.offsetX;
ev.y_ = ev.offsetY;
}
// Call the event handler of the active tool.
var func = _self.tool[ev.type];
if (typeof func != 'function') {
return false;
}
res = func(ev);
/*
* If the event handler from the current tool does return false, it means it
* did not execute for some reason. For example, in a keydown
* event handler the keyboard shortcut does not match some criteria, thus the
* handler returns false, leaving the event continue its normal flow.
*/
if (res !== false && ev.preventDefault) {
ev.preventDefault();
}
return res;
};
Next, you need to update the init()
function to make sure
our global keyboard shortcuts event handler is invoked for all keyboard
events:
function init () {
// ...
// Add the keyboard events handler.
kbListener_ = new lib.dom.KeyboardEventListener(window,
{keydown: ev_keyhandler, keypress: ev_keyhandler, keyup: ev_keyhandler});
};
The previous event handler ev_keydown()
is now maturing,
becoming the global keyboard events handler,
ev_keyhandler()
.
Now we have to update the ev_keyhandler()
function to modify
the DOM Event object. Just like with the mouse coordinates, you want to pass
to the event handler of the current tool the key and the "key ID"
properties. The key_ property is already added to the event
object so we only have to add the kid_ property. We then have to
call the ev_canvas()
function with the modified event object.
We do this because that function will also include the mouse coordinates
into the DOM Event object, and it will call the event handler of the current
tool (if there is one defined). If ev_canvas()
returns some
result, we know to stop the execution; in such cases we allow the drawing
tools to overwrite any global keyboard shortcut.
Here is a code snippet from the updated ev_keyhandler()
function:
// Determine the key ID.
ev.kid_ = '';
var i, kmods = {altKey: 'Alt', ctrlKey: 'Control', shiftKey: 'Shift'};
for (i in kmods) {
if (ev[i] && ev.key_ != kmods[i]) {
ev.kid_ += kmods[i] + ' ';
}
}
ev.kid_ += ev.key_;
/*
* Send the event to the canvas, and eventually to the keydown event handler
* of the currently active tool (if any).
* The effect of calling ev_canvas() is that the event object *might* have the
* x_ and y_ coordinate properties added. Additionally, if ev_canvas() returns
* some result, we can use it to cancel any global keyboard shortcut.
*/
var canvas_result = ev_canvas(ev);
if (canvas_result) {
return;
}
As you can see, a new property is added to the ev object: ev.kid_ is a string holding the key and the modifiers list (Control, Alt and/or Shift). For example, if the user presses the A key while holding down Control, then ev.kid_ is "Control A". If the user presses "9" while holding down Shift, then ev.kid_ is "Shift 9".
That's all you need to change in the main code from app 3. Now let's update the drawing tools.
Updating the drawing tools
For the rectangle tool we can implement support for pressing the Escape key to cancel the drawing operation, and we can also implement support for using the Shift key to force the side lengths to remain the same, so you can draw perfect squares if desired.
Here we have a snippet from the updated rectangle tool implementation:
PaintTools.rect = function (app) {
// ...
this.mousemove = function (ev) {
// ...
// Constrain the shape to a square when the user holds down the Shift key.
if (ev.shiftKey) {
if (w > h) {
if (y == ev.y_) {
y -= w-h;
}
h = w;
} else {
if (x == ev.x_) {
x -= h-w;
}
w = h;
}
}
context.strokeRect(x, y, w, h);
};
// ...
this.keydown = function (ev) {
if (!started || ev.kid_ != 'Escape') {
return false;
}
context.clearRect(0, 0, canvas.width, canvas.height);
started = false;
};
};
The mousemove
event handler implements the constraining of
the shape to a square when the user holds down the Shift key.
The new keydown
event handler is straight-forward and easy: it
returns false if the drawing operation is not started, or if the key
combination is unrecognized. If the user pressed the Escape key,
then the method simply clears the buffer and ends the drawing operation.
Similarly, we can also update the line tool so it allows users to hold down the Shift key to force drawing a straight horizontal/vertical line, or to press the Escape key to cancel the drawing operation. Here is the updated code:
PaintTools.line = function (app) {
// ...
this.mousemove = function (ev) {
// ...
// Snap the line to be horizontal or vertical, when the Shift key is down.
if (ev.shiftKey) {
var diffx = Math.abs(ev.x_ - x0),
diffy = Math.abs(ev.y_ - y0);
if (diffx > diffy) {
ev.y_ = y0;
} else {
ev.x_ = x0;
}
}
// ...
};
// ...
this.keydown = function (ev) {
if (!started || ev.kid_ != 'Escape') {
return false;
}
context.clearRect(0, 0, canvas.width, canvas.height);
started = false;
};
};
For all intents and purposes, the keydown
event handler is
identical to the one in the rectangle tool; it's just that the
implementation for the line horizontal/vertical snapping is slightly
different.
That's all! Try the updated application and check out the updated documentation found in the app3 directory in the code download.
In the next part
In the second part of the article we extend the functionality of the paint application to allow users to draw with the keyboard, without any pointing device.
If you want to learn more, stick around at DevOpera and also check for the upcoming changes to PaintWeb - the open-source web application this article is based on.
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.