Dive Into Greasemonkey

Teaching an old web new tricks

5.8. Case study: Access Bar

Displaying accesskeys in a fixed status bar

Access Bar displays accesskeys defined on a web page. Accesskeys are an accessibility feature that allow you to jump to a specific link or form field with a keyboard shortcut defined by the page author. (Learn more about accesskeys.)

Firefox supports accesskeys, but it does not display which keys are defined on a page. Thus, Access Bar was born.

Example: accessbar.user.js

// ==UserScript==
// @name          Access Bar
// @namespace     http://diveintogreasemonkey.org/download/
// @description   show accesskeys defined on page
// @include       *
// ==/UserScript==

function addGlobalStyle(css) {
    var head, style;
    head = document.getElementsByTagName('head')[0];
    if (!head) { return; }
    style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML = css;
    head.appendChild(style);
}

var akeys, descriptions, a, desc, label, div;
akeys = document.evaluate(
    "//*[@accesskey]",
    document,
    null,
    XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
    null);
if (!akeys.snapshotLength) { return; }
descriptions = new Array();
desc = '';
for (var i = 0; i < akeys.snapshotLength; i++) {
    a = akeys.snapshotItem(i);
    desctext = '';
    if (a.nodeName == 'INPUT') {
        label = document.evaluate("//label[@for='" + a.name + "']",
            document,
            null,
            XPathResult.FIRST_ORDERED_NODE_TYPE,
            null).singleNodeValue;
        if (label) {
            desctext = label.title;
            if (!desctext) { desctext = label.textContent; }
        }
    }
    if (!desctext) { desctext = a.textContent; }
    if (!desctext) { desctext = a.title; }
    if (!desctext) { desctext = a.name; }
    if (!desctext) { desctext = a.id; }
    if (!desctext) { desctext = a.href; }
    if (!desctext) { desctext = a.value; }
    desc = '<strong>[' +
        a.getAttribute('accesskey').toUpperCase() + ']</strong> ';
    if (a.href) {
        desc += '<a href="' + a.href + '">' + desctext + '</a>';
    } else {
        desc += desctext;
    }
    descriptions.push(desc);
}
descriptions.sort();
div = document.createElement('div');
div.id = 'accessbar-div-0';
desc = '<div><ul><li class="first">' + descriptions[0] + '</li>';
for (var i = 1; i < descriptions.length; i++) {
    desc = desc + '<li>' + descriptions[i] + '</li>';
}
desc = desc + '</ul></div>';
div.innerHTML = desc;
document.body.style.paddingBottom = "4em";
window.addEventListener(
    "load",
    function() {
        document.body.appendChild(div);
    },
    true);
addGlobalStyle(
'#accessbar-div-0 {'+
'  position: fixed;' +
'  left: 0;' +
'  right: 0;' +
'  bottom: 0;' +
'  top: auto;' +
'  border-top: 1px solid silver;' +
'  background: black;' +
'  color: white;' +
'  margin: 1em 0 0 0;' +
'  padding: 5px 0 0.4em 0;' +
'  width: 100%;' +
'  font-family: Verdana, sans-serif;' +
'  font-size: small;' +
'  line-height: 160%;' +
'}' +
'#accessbar-div-0 a,' +
'#accessbar-div-0 li,' +
'#accessbar-div-0 span,' +
'#accessbar-div-0 strong {' +
'  background-color: transparent;' +
'  color: white;' +
'}' +
'#accessbar-div-0 div {' +
'  margin: 0 1em 0 1em;' +
'}' +
'#accessbar-div-0 div ul {' +
'  margin-left: 0;' +
'  margin-bottom: 5px;' +
'  padding-left: 0;' +
'  display: inline;' +
'}' +
'#accessbar-div-0 div ul li {' +
'  margin-left: 0;' +
'  padding: 3px 15px;' +
'  border-left: 1px solid silver;' +
'  list-style: none;' +
'  display: inline;' +
'}' +
'#accessbar-div-0 div ul li.first {' +
'  border-left: none;' +
'  padding-left: 0;' +
'}');

The code breaks down into six steps:

  1. Define a helper function, addGlobalStyle
  2. Find all the page elements that include an accesskey attribute
  3. Loop through those elements and determine the most appropriate text to describe each element
  4. Construct a sorted list of links to the accesskey-enabled elements
  5. Add the sorted list to the page, using standard ul and li elements
  6. Style the list so it appears as a fixed faux-status-bar at the bottom of the viewport

First things first. I need a helper function, addGlobalStyle, to inject my own CSS styles (used in step 6). See Adding CSS styles for more details on this pattern.

function addGlobalStyle(css) {
    var head, style;
    head = document.getElementsByTagName('head')[0];
    if (!head) { return; }
    style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML = css;
    head.appendChild(style);
}

Step 2 gets a list of all the page elements that contain an accesskey attribute. This is easy with Firefox's XPath support. Note that if the XPath query returns no results, I simply bail, since there will be nothing to display. See Doing something for every element with a certain attribute for more information on this pattern.

var akeys, descriptions, a, i, desc, label, div;
akeys = document.evaluate(
    "//*[@accesskey]",
    document,
    null,
    XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
    null);
if (!akeys.snapshotLength) { return; }

Step 3, constructing the list of suitable descriptions for each accesskey-enabled element, is the most complicated part of the script. The problem is that accesskey attributes can appear in several different HTML elements.

An input element in a form can define an accesskey. input elements may or may not have an associated label that contains an associated text label for the input field. If so, the label may contain a title attribute that gives even more detailed information about the input field. Or the label attribute may simply contain text. Or the input element may have no associated label at all, in which case the value attribute of the input element is the best I can do.

On the other hand, the label itself can define the accesskey, instead of the input element the label describes. Again, I'll look for a description the title attribute of the label element, but fall back to the text of the label if no title attribute is present.

A link can also define an accesskey attribute. If so, the link text is the obvious choice. But if the link has no text (for example, if it only contains an image), then the link's title attribute is the next place to look. If the link contains no text and no title, I fall back to the link's name attribute, and failing that, the link's id attribute.

Ain't heuristics a bitch? Here's the full algorithm. Remember, akeys is an XPathResult object, so I need to get each result by calling akeys.snapshotItem(i).

descriptions = new Array();
desc = '';
for (var i = 0; i < akeys.snapshotLength; i++) {
    a = akeys.snapshotItem(i);
    desctext = '';
    if (a.nodeName == 'INPUT') {
        label = document.evaluate("//label[@for='" + a.name + "']",
            document,
            null,
            XPathResult.FIRST_ORDERED_NODE_TYPE,
            null).singleNodeValue;
        if (label) {
            desctext = label.title;
            if (!desctext) { desctext = label.textContent; }
        }
    }
    if (!desctext) { desctext = a.textContent; }
    if (!desctext) { desctext = a.title; }
    if (!desctext) { desctext = a.name; }
    if (!desctext) { desctext = a.id; }
    if (!desctext) { desctext = a.href; }
    if (!desctext) { desctext = a.value; }
    desc = '<strong>[' +
        a.getAttribute('accesskey').toUpperCase() + ']</strong> ';
    if (a.href) {
        desc += '<a href="' + a.href + '">' + desctext + '</a>';
    } else {
        desc += desctext;
    }
    descriptions.push(desc);
}

Step 4 is simple, since Javascript arrays have a sort method that sorts the array in place.

descriptions.sort();

Step 5 creates the HTML to render the list of accesskey-enabled elements. I create a wrapper <div>, construct the HTML for the list of accesskeys as a string, set the wrapper <div>'s innerHTML property, and finally add it to the end of the page. Because of when user scripts are executed, complex changes to a page should be delayed until after the page is done loading, so I use window.addEventListener to add an onload event that adds the wrapper <div> to the page.

See Inserting complex HTML quickly for more information on the use of innerHTML, and Post-processing a page after it renders for information on the use of window.addEventListener.

div = document.createElement('div');
div.id = 'accessbar-div-0';
desc = '<div><ul><li class="first">' + descriptions[0] + '</li>';
for (var i = 1; i < descriptions.length; i++) {
    desc = desc + '<li>' + descriptions[i] + '</li>';
}
desc = desc + '</ul></div>';
div.innerHTML = desc;
document.body.style.paddingBottom = "4em";
window.addEventListener(
    "load",
    function() {
        document.body.appendChild(div);
    },
    true);

Finally, in step 6, I add my own set of CSS declarations to the page so that the HTML I'm injecting will look pretty. (Specifically, it will appear as a fixed black bar along the bottom of the page, that stays in view even as you scroll the page. This is made possible by Firefox's support for the position:fixed display type.) See Adding CSS styles for more information.

addGlobalStyle(
'#accessbar-div-0 {'+
'  position: fixed;' +
'  left: 0;' +
'  right: 0;' +
'  bottom: 0;' +
'  top: auto;' +
'  border-top: 1px solid silver;' +
'  background: black;' +
'  color: white;' +
'  margin: 1em 0 0 0;' +
'  padding: 5px 0 0.4em 0;' +
'  width: 100%;' +
'  font-family: Verdana, sans-serif;' +
'  font-size: small;' +
'  line-height: 160%;' +
'}' +
'#accessbar-div-0 a,' +
'#accessbar-div-0 li,' +
'#accessbar-div-0 span,' +
'#accessbar-div-0 strong {' +
'  background-color: transparent;' +
'  color: white;' +
'}' +
'#accessbar-div-0 div {' +
'  margin: 0 1em 0 1em;' +
'}' +
'#accessbar-div-0 div ul {' +
'  margin-left: 0;' +
'  margin-bottom: 5px;' +
'  padding-left: 0;' +
'  display: inline;' +
'}' +
'#accessbar-div-0 div ul li {' +
'  margin-left: 0;' +
'  padding: 3px 15px;' +
'  border-left: 1px solid silver;' +
'  list-style: none;' +
'  display: inline;' +
'}' +
'#accessbar-div-0 div ul li.first {' +
'  border-left: none;' +
'  padding-left: 0;' +
'}');
← Case study: Zoom Textarea
Advanced Topics →