Keyboard accessibility in web applications - part one

Author:

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. 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) 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 and the documentation 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:

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

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 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. Also, make sure to take a look at the documentation for the source code.

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. 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 the updated documentation generated from the source code.

In the next part

In the second part of the article we will 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.