Keyboard accessibility in web applications - part three

Author:

Table of contents

Introduction

In the first part of the article we have added keyboard shortcuts to a painting application. In the second part we implemented a way to move the pointer inside the drawing canvas using keyboard shortcuts. Now we will look into the cross-browser compatibility layer implementation for handling all the differences between browsers.

To make it easier to follow along with the code walkthrough presented below, download the full code example and follow along with it as you read the article.

Overview

There are important differences between browsers with regards to keyboard events. Differences apply to the properties and the values they provide in the DOM Event objects. There are also differences that affect the firing of the events, and default action prevention. Last but not least, the same browser can behave differently on a different operating system.

Keyboard events have several important properties:

So this is the big picture - you are already getting an idea of how difficult is to identify a key from a keyboard event. In short, the browser differences you need to be aware of are as follows:

As I said before, there are differences between browsers with regards to event firing as well:

Another important difference between browsers is how they repeat the keyboard events, while the user holds down the same key for longer periods of time. For example, Firefox on Linux only repeats the keypress event. However, Firefox on Windows repeats both events, the keydown and the keypress events.

This is only an overview of browser differences, and it's not highly detailed. For more details, you might want to check out the code comments in the lib.js file.

The implementation

The minimal JavaScript library code tries to determine the accurate keyCode and charCode. The keypress event is dispatched synthetically for all keys, except modifiers. The keydown event is never repeated; only the keypress event is repeated.

Here is the basic structure of the KeyboardEventListener class:

lib.dom.KeyboardEventListener = function (elem_, handlers_) {
  var keyCode_ = null;
  var key_ = null;
  var charCode_ = null;
  var char_ = null;
  var repeat_ = false;

  this.attach = function () { ... };
  this.detach = function () { ... };

  function dispatch (type, ev) { ... };

  function keydown (ev) { ... };
  function keypress (ev) { ... };
  function keyup (ev) { ... };

  function isModifierKey (key) { ... };
  function firesKeyPress (ev) { ... };

  function findKeyCode (ev) { ... };
  function findCharCode (ev) { ... };

  this.attach();
};

When you create a new instance of the KeyboardEventListener class you need to provide the DOM Element you want to listen for keyboard events on, the elem_ argument. You also have to provide the event handler(s) you want for each keyboard event: keydown, keypress and keyup. The handlers_ argument must be an object holding the event names as properties, with the values are the functions, the handlers you want for each event. Example code:

var klogger = function (ev) {
  console.log(ev.type +
    ' keyCode_ ' + ev.keyCode_ +
    ' key_ ' + ev.key_ +
    ' charCode_ ' + ev.charCode_ +
    ' char_ ' + ev.char_ +
    ' repeat_ ' + ev.repeat_);
};

var kbListener = new lib.dom.KeyboardEventListener(window,
              {keydown: klogger,
               keypress: klogger,
               keyup: klogger});

// later when you are done...
kbListener.detach();

The event listeners are automatically attached to the element you give when the KeyboardEventListener class is instanced. Here is the code used for attaching and detaching the event listeners:

/**
 * Attach the keyboard event listeners to the current DOM element.
 */
this.attach = function () {
  keyCode_ = null;
  key_ = null;
  charCode_ = null;
  char_ = null;
  repeat_ = false;

  elem_.addEventListener('keydown',  keydown,  false);
  elem_.addEventListener('keypress', keypress, false);
  elem_.addEventListener('keyup',    keyup,    false);
};

/**
 * Detach the keyboard event listeners from the current DOM element.
 */
this.detach = function () {
  elem_.removeEventListener('keydown',  keydown,  false);
  elem_.removeEventListener('keypress', keypress, false);
  elem_.removeEventListener('keyup',    keyup,    false);

  keyCode_ = null;
  key_ = null;
  charCode_ = null;
  char_ = null;
  repeat_ = false;
};

As you can see, the event handlers are not used here. The event handlers are the private functions, keydown(), keypress() and keyup(). Each of these functions will deal individually with browser differences, and they will call the event handlers you provided.

The event handlers

Here we have the code for the keydown() event handler:

function keydown (ev) {
  var prevKey = key_;

  charCode_ = null;
  char_ = null;

  findKeyCode(ev);

  ev.keyCode_ = keyCode_;
  ev.key_ = key_;
  ev.repeat_ = key_ && prevKey == key_ ? true : false;

  repeat_ = ev.repeat_;

  // Do not repeat keydown events.
  if (!repeat_) {
    dispatch('keydown', ev);
  }

  // MSIE and Webkit only fire the keypress event for characters 
  // (alpha-numeric and symbols).
  if (!isModifierKey(key_) && !firesKeyPress(ev)) {
    ev.type_ = 'keydown';
    keypress(ev);
  }
};

This function determines the keyCode_ and the key_ variables for the current event, using the findKeyCode() function. The two variables are added as new properties to the DOM Event object, such that the event handler you provided will be able to use the values. If the keydown event is repeated, then ev.repeat_ will be set to true. The event is dispatched to the keydown handler you provided using the dispatch() function only if the event is not repeating.

Another issue being tackled by the keydown() function is the firing of the keypress event. As described above, Webkit and IE do not always fire the event. Therefore, if the key is not a modifier, and if the firesKeyPress() function returns false, then we manually invoke the keypress() event handler.

The charCode_ and char_ variables are reset to null, because the character is never determined during the keydown event.

Before we see the code for the functions used by the keydown event handler, let's take a look at the code of the other two event handlers. Here is the keypress() function:

function keypress (ev) {
  if (!keyCode_) {
    findKeyCode(ev);
    repeat_ = false;
  }

  ev.keyCode_ = keyCode_;
  ev.key_ = key_;

  findCharCode(ev);

  ev.charCode_ = charCode_;
  ev.char_ = char_;

  // Any subsequent keypress event is considered a repeated keypress (the user 
  // is holding down the key).
  ev.repeat_ = repeat_;
  if (!repeat_) {
    repeat_ = true;
  }

  if (!isModifierKey(key_)) {
    dispatch('keypress', ev);
  }
};

This code expects the keyCode_ and key_ variables to be set from the keydown event; we can only determine the charCode_ and the char_ variables using the findCharCode() function. As described above, in general cases the keydown event gives the key code, and the keypress event gives the character code. Still, if for some reason the key was not determined during the keydown event, the code tries again.

The keypress event is only dispatched to the event handler you provided if it is not a modifier key. Here is the keyup event handler:

function keyup (ev) {
  findKeyCode(ev);

  ev.keyCode_ = keyCode_;
  ev.key_ = key_;

  // Provide the character info from the keypress event in keyup as well.
  ev.charCode_ = charCode_;
  ev.char_ = char_;

  dispatch('keyup', ev);

  keyCode_ = null;
  key_ = null;
  charCode_ = null;
  char_ = null;
  repeat_ = false;
};

Here we are trying to determine the keyCode_ for the keyup event again, even if we might already have it from the previous keydown event. This is needed because the user might press some key which only generates the keydown and the keypress events, after which a sudden keyup event is fired for a completely different key. For example, in Opera press F2 then Escape. It will first generate two events - keydown and keypress - for the F2 key. When you press Escape to close the dialog box, the script receives the keyup event for the Escape key.

After the keyup event is dispatched to the event handler you provided, any information stored during the event flow is lost, to prevent any mix-up.

Helper functions

Here is the simple isModifierKey() function:

function isModifierKey (key) {
  switch (key) {
    case 'Shift':
    case 'Control':
    case 'Alt':
    case 'Meta':
    case 'Win':
      return true;
    default:
      return false;
  }
};

The function is as straightforward as it can be.

Next, let's look at the function that determines if the browser will fire a keypress event or not:

function firesKeyPress (ev) {
  if (!lib.browser.msie && !lib.browser.webkit) {
    return true;
  }

  // Check if the key is a character key, or not.
  // If it's not a character, then keypress will not fire.
  // Known exceptions: keypress fires for Space, Enter and Escape in MSIE.
  if (key_ && key_ != 'Space' && key_ != 'Enter' && key_ != 'Escape' && 
      key_.length != 1) {
    return false;
  }

  // Webkit doesn't fire keypress for Escape as well ...
  if (lib.browser.webkit && key_ == 'Escape') {
    return false;
  }

  // MSIE does not fire keypress if you hold Control / Alt down, while Shift is off.
  if (lib.browser.msie && !ev.shiftKey && (ev.ctrlKey || ev.altKey)) {
    return false;
  }

  return true;
};

There is no way to detect if the keypress event will really fire or not. We simply rely on the knowledge we have based on testing and online documentation.

The keyCode_ and key_ variables above are determined by the findKeyCode() function:

function findKeyCode (ev) {
  /*
   * If the event has no keyCode/which/keyIdentifier values, then simply do 
   * not overwrite any existing keyCode_/key_.
   */
  if (ev.type == 'keyup' && !ev.keyCode && !ev.which && (!ev.keyIdentifier || 
        ev.keyIdentifier == 'Unidentified' || ev.keyIdentifier == 'U+0000')) {
    return;
  }

  keyCode_ = null;
  key_ = null;

  // Try to use keyCode/which.
  if (ev.keyCode || ev.which) {
    keyCode_ = ev.keyCode || ev.which;

    // Fix Webkit quirks
    if (lib.browser.webkit) {
      // Old Webkit gives keyCode 25 when Shift+Tab is used.
      if (keyCode_ == 25 && this.shiftKey) {
        keyCode_ = lib.dom.keyNames.Tab;
      } else if (keyCode_ >= 63232 && keyCode_ in lib.dom.keyCodes_Safari2) {
        // Old Webkit gives wrong values for several keys.
        keyCode_ = lib.dom.keyCodes_Safari2[keyCode_];
      }
    }

    // Fix keyCode quirks in all browsers.
    if (keyCode_ in lib.dom.keyCodes_fixes) {
      keyCode_ = lib.dom.keyCodes_fixes[keyCode_];
    }

    key_ = lib.dom.keyCodes[keyCode_] || String.fromCharCode(keyCode_);

    return;
  }

  // Try to use ev.keyIdentifier. This is only available in Webkit and 
  // Konqueror 4, each having some quirks. Sometimes the property is needed, 
  // because keyCode/which are not always available.

  var key = null,
      keyCode = null,
      id = ev.keyIdentifier;

  if (!id || id == 'Unidentified' || id == 'U+0000') {
    return;
  }

  if (id.substr(0, 2) == 'U+') {
    // Webkit gives character codes using the 'U+XXXX' notation, as per spec.
    keyCode = parseInt(id.substr(2), 16);

  } else if (id.length == 1) {
    // Konqueror 4 implements keyIdentifier, and they provide the Unicode 
    // character directly, instead of using the 'U+XXXX' notation.
    keyCode = id.charCodeAt(0);
    key = id;

  } else {
    /*
     * Common keyIdentifiers like 'PageDown' are used as they are.
     * We determine the common keyCode used by Web browsers, from the 
     * lib.dom.keyNames object.
     */
    keyCode_ = lib.dom.keyNames[id] || null;
    key_ = id;

    return;
  }

  // Some keyIdentifiers like 'U+007F' (127: Delete) need to become key names.
  if (keyCode in lib.dom.keyCodes && (keyCode <= 32 || keyCode == 127 || keyCode 
        == 144)) {
    key_ = lib.dom.keyCodes[keyCode];
  } else {
    if (!key) {
      key = String.fromCharCode(keyCode);
    }

    // Konqueror gives lower-case chars
    key_ = key.toUpperCase();
    if (key != key_) {
      keyCode = key_.charCodeAt(0);
    }
  }

  // Correct the keyCode, make sure it's a common keyCode, not the Unicode 
  // decimal representation of the character.
  if (key_ == 'Delete' || key_.length == 1 && key_ in lib.dom.keyNames) {
    keyCode = lib.dom.keyNames[key_];
  }

  keyCode_ = keyCode;
};

The findKeyCode() function tries to determine the key and its code using ev.keyCode or ev.which. If these two properties are not available, then the ev.keyIdentifier property is checked.

When the keyCode or which property is available, the value is adjusted/corrected for each browser. Mainly, Webkit is known to give the key code 25 when the key combination Shift + Tab is used. Additionally, older Webkit versions gave incorrect values for several keys. There are several incorrect key codes in several browsers, so we use the lib.dom.keyCodes_fixes map.

We have the lib.dom.keyCodes object holding the list of key codes associated to key names/identifiers. This is needed so we can provide an easy to remember, human-readable value for the key_ property, which is added to the DOM Event object. If the key code is not found in the list, the code is used as a Unicode decimal value, converted to a string with the String.fromCharCode() function.

If the keyIdentifier event property is used, we will try to parse the "U+XXXX" notation. We also check to see if it is a single character, like in Konqueror 4. If the value is not in one of these forms, it is considered to hold the key name, like Enter or PageDown. We have the lib.dom.keyNames object holding a list of common key names associated to their key code. The key_ value will be the same as the one in the keyIdentifier property.

The DOM 3 Events specification defines that for some keys, like Delete; the value for the keyIdentifier property is that of the Unicode character. So, from the Unicode specification, or from the ASCII table, "U+007F" is Delete, "U+0009" is Tab, etc. In these cases, the findKeyCode() tries to also determine the key name.

The function that determines the charCode_ and the char_ properties is as follows:

function findCharCode (ev) {
  charCode_ = null;
  char_ = null;

  // Webkit and Gecko implement ev.charCode.
  if (ev.charCode) {
    charCode_ = ev.charCode;
    char_ = String.fromCharCode(ev.charCode);

    return;
  }

  if (ev.keyCode || ev.which) {
    var keyCode = ev.keyCode || ev.which;

    var force = false;

    // We accept some keyCodes.
    switch (keyCode) {
      case lib.dom.keyNames.Tab:
      case lib.dom.keyNames.Enter:
      case lib.dom.keyNames.Space:
        force = true;
    }

    // Do not consider the keyCode a character code, if during the keydown 
    // event it was determined the key does not generate a character, unless 
    // it's Tab, Enter or Space.
    if (!force && key_ && key_.length != 1) {
      return;
    }

    // If the keypress event at hand is synthetically dispatched by keydown, 
    // then special treatment is needed. This happens only in Webkit and MSIE.
    if (ev.type_ == 'keydown') {
      var key = lib.dom.keyCodes[keyCode];
      // Check if the keyCode points to a single character.
      // If it does, use it.
      if (key && key.length == 1) {
        charCode_ = key.charCodeAt(0); // keyCodes != charCodes
        char_ = key;
      }
    } else if (keyCode >= 32 || force) {
      // For normal keypress events, we are done.
      charCode_ = keyCode;
      char_ = String.fromCharCode(keyCode);
    }

    if (charCode_) {
      return;
    }
  }

  /*
   * Webkit and Konqueror do not provide a keyIdentifier in the keypress 
   * event, as per spec. However, in the unlikely case when the keyCode is 
   * missing, and the keyIdentifier is available, we use it.
   *
   * This property might be used when a synthetic keypress event is generated 
   * by the keydown event, and keyCode/charCode/which are all not available.
   */

  var c = null,
      charCode = null,
      id = ev.keyIdentifier;

  if (id && id != 'Unidentified' && id != 'U+0000' &&
      (id.substr(0, 2) == 'U+' || id.length == 1)) {

    // Characters in Konqueror...
    if (id.length == 1) {
      charCode = id.charCodeAt(0);
      c = id;

    } else {
      // Webkit uses the 'U+XXXX' notation as per spec.
      charCode = parseInt(id.substr(2), 16);
    }

    if (charCode == lib.dom.keyNames.Tab ||
        charCode == lib.dom.keyNames.Enter ||
        charCode >= 32 && charCode != 127 &&
        charCode != lib.dom.keyNames.NumLock) {

      charCode_ = charCode;
      char_ = c || String.fromCharCode(charCode);

      return;
    }
  }

  // Try to use the key determined from the previous keydown event, if it 
  // holds a character.
  if (key_ && key_.length == 1) {
    charCode_ = key_.charCodeAt(0);
    char_ = key_;
  }
};

The findCharCode() first tries to use the charCode event property. If it is available, then we are done.

When the event keyCodeor which property needs to be used, we must check if the key determined during the keydown event generates a character or not. If it is PageDown or some other non-character key, then we cannot use the keyCode property of the current keypress event.

If the keyCode and the which event properties are not available, we try the keyIdentifier property. This property is not available in the keypress event, but it can be available when the keydown event synthetically dispatches the keypress event. The keyIdentifier property is checked for a Unicode character value of the form "U+XXXX". For Konqueror compatibility, we also check if the property value is only one character. These values are used only if they do not hold something like "U+007F" (the Delete key).

The event dispatcher function code:

function dispatch (type, ev) {
  if (!handlers_[type]) {
    return;
  }

  var handler = handlers_[type];

  if (type == ev.type) {
    handler.call(elem_, ev);

  } else {
    // This happens when the keydown event tries to dispatch a keypress event.

    var ev_new = {};
    lib.extend(ev_new, ev);
    ev_new.type = type;

    // Make sure preventDefault() still works
    ev_new.preventDefault = function () {
      ev.preventDefault();
    };

    handler.call(elem_, ev_new);
  }
};

This function checks if you provided an event handler for the given event type. If you have one, then the handler is invoked with the updated DOM Event object. The DOM Event object is faked if the event type being dispatched does not match the type of the original event - this happens when the keydown event handler dispatches a keypress event.

That is all! You should take a look into the new lib.js file and check out how everything ties together.

Summary

This three-part article series has provided pretty much everything you need to know to implement cross platform keyboard controls for complicated web applications. It's now up to you to start taking these techniques, experimenting with them, and making use of them in your own applications.

In this article series we tackled one aspect from what it takes to make a mature web application; there are always other things to do, more improvements to make! Some more improvements you may want to think about when you get time are:

If you want to learn more, stick around at DevOpera and also check the upcoming changes to PaintWeb - the open-source web application this article is based on.