Friday, 21 May 2010

Using jQuery to enable keyboard navigation of a survey form that uses radio buttons in ASP.NET

I had been meaning to blog about this for awhile but haven’t had a chance until now. I did some work for my previous employer where the client had a survey form written in ASP.NET, using lots of radio buttons. The radio buttons were generated by a Repeater control.

The problem was that these surveys could get quite long and run over many pages. This meant the user would be doing a lot of mouse clicking and could end up getting carpal-tunnel syndrome by the time they were done, which could cause future legal problems! ;-). So the request was to enable some sort of keyboard navigation. As it was a purely client-side issue, it seemed like a perfect opportunity to use jQuery in conjunction with ASP.NET to enable the desired functionality.

I figured the most intuitive behavior would be as follows:

  • Give the user a visual clue as to what the “current” radio button is. Use CSS to style the button to put a box around it.
  • Use left, right, up and down arrow keys to go from the “current” button to the next appropriate button.
  • When you get to the end, wrap back around. So if you are at the top row and the user hits the up arrow key, the next “current” button would be at the bottom row. If you’re already at the far right, and you hit the right arrow key, the “current” button would flip back to the beginning of the row (i.e. the leftmost button).
  • When the user wants to “select” the current button (i.e., have the page behave as if the user had physically clicked that button with their mouse), let them use the Space bar or the Enter key to indicate selection.

The idea is simply to use the fact that the repeater control was generating radio button control names that sort of leant themselves to a kind of X,Y coordinate system whereby, if one knew the naming convention, one could figure out which row and column the current and desired target radio buttons. (Theoretically this could work with any technology, not just ASP.NET, assuming the buttons had a consistent naming convention that identified their X,Y coordinate in a grid of radio buttons).

I used a jQuery plug-in called jQuery hotkeys to handle the keyboard events. This worked beautifully. At the time I was using version 0.7.9 but it seems there have been some updates since then.

So apart from putting the jQuery library and jQuery hotkeys into your app, basically there are a few components:

1. The page source HTML that gets generated by ASP.NET, which includes the repeater control that renders radio buttons using some naming convention like this:

ctl00_mainContent_Repeater1_ctl00_AnswerControl_AnswerButtonList_0

a. The main pertinent bits that exist in the ASPX file are a ScriptManagerProxy reference which wraps the ScriptReference objects (I also had a div called “jQueryTestOutput” where I could use jQuery to write things while testing):

<div>

<asp:ScriptManagerProxy ID="ScriptManagerProxy1" runat="server">

<Scripts>

<asp:ScriptReference Path="~/js/jquery.hotkeys-0.7.9.js" />

<asp:ScriptReference Path="~/js/rvwAnswerKeyNav.js" />

</Scripts>

</asp:ScriptManagerProxy>

<div id="jQueryTestOutput">

</div>

</div>

b. There is also the reference to the repeater object (in this case I didn’t change what existed before, which was to format the output using HTML tables; you could use this same idea with different formatting, but in my case, if you look at my jQuery code below, “setPreviousAndCurrentCellStyles”, you can see I used jQuery to make a .css call that would re-style the TD that was wrapped around the the radio button) .

<table class="AnswerTable">

<tr>

<asp:Repeater ID="AnswerColumnRepeater" runat="server">

<ItemTemplate>

<td align="left">

<asp:Label ID="AnswerHeading" runat="server">

<%# ((SomeClassLibraryReference)Container.DataItem).Description %>

</asp:Label>

</td>

</ItemTemplate>

</asp:Repeater>

</tr>

</table>

2. Finally there is the jQuery code itself, which in this case is a file called “rvwAnswerKeyNav.js”. I’m not going to explain everything as it’s pretty well commented and should be fairly self-explanatory. Essentially I’m keeping track of the current button, and figuring out the target button based on which key the user hit. Then I re-style the old (button the user just left) and the new (target button the user wants to go to). If the user hits Space or Enter, I get the string representing the current button, and using that as the jQuery selector, I execute the .click method.

/*

jQuery code to handle arrow key navigation on Review/ViewReviewQuestions.aspx

This script works based on the fact that the radio buttons are named like this:

ctl00_mainContent_Repeater1_ctl00_AnswerControl_AnswerButtonList_0

where the "00" in the middle (substring 31, 2) will represent the row coordinate,

and the "0" at the end (substring 65, 2) will represent the column coordinate.

This makes it possible to figure out, when the user hits an arrow key,

which is the next radio button that should get the focus.

The illusion of "focus" is created with CSS styling of the TD element containing the button.

Then the Enter key or the space bar will cause the button to be clicked.

*/

// document ready handler

$(function() {

// an object to store global settings

var settings =

{

rowCeiling: 0,

columnCeiling: 0,

currentRow: 0,

currentColumn: 0,

previousCurrentRow: 0,

previousCurrentColumn: 0

}

$("form").data("globalSettings", settings);

// set row/column ceiling values by processing radio button set

$("input[type=radio]").SetRowAndColumnCeilings();

// keypress handling utilizes jQuery plug-in: js/jquery.hotkeys-0.7.9.js

// bind an event handler to each radio button to capture keypress events

$(document).bind('keydown', 'left', $.HandleLeftArrowKeypress);

$(document).bind('keydown', 'right', $.HandleRightArrowKeypress);

$(document).bind('keydown', 'up', $.HandleUpArrowKeypress);

$(document).bind('keydown', 'down', $.HandleDownArrowKeypress);

$(document).bind('keydown', 'space', $.HandleSpaceBarKeypress);

$(document).bind('keydown', 'return', $.HandleEnterKeypress);

// select the first radio button

$.SetCurrentCellStyle();

});

// utility and wrapper functions

(function($) {

// for testing purposes, a function to display output to the page

// (depends on the presence of a "jQueryTestOutput" element on ViewReviewQuestions.aspx)

$.say = function(text) {

$('#jQueryTestOutput').append('<div>' + text + '</div>');

}

// These "get position" functions assume the radio buttons are named like this:

// ctl00_mainContent_Repeater1_ctl00_AnswerControl_AnswerButtonList_0

// note this obscure JavaScript fact: if you try this:

// var myInt = parseInt('08') you will get zero as a result.

// why? because it treats '08' as an octal. Solution

// is to use parseInt('08',10) to explicitly indicate base 10.

// see http://blog.vishalon.net/index.php/be-careful-when-you-are-using-javascript-parseint-function

$.GetRowPositionFromID = function(text) {

// use base 10 radix to ensure correct results

return parseInt(text.substr(31, 2), 10);

}

$.GetColumnPositionFromID = function(text) {

// use base 10 radix to ensure correct results

return parseInt(text.substr(65, 2), 10);

}

$.DisplayRowColumnSettings = function() {

// for testing purposes

$.say("old row: " + $("form").data("globalSettings").previousCurrentRow +

", old col: " + $("form").data("globalSettings").previousCurrentColumn +

", new row: " + $("form").data("globalSettings").currentRow +

", new col: " + $("form").data("globalSettings").currentColumn);

}

$.HandleLeftArrowKeypress = function() {

/*

We're going left; that means the row will remain unchanged.

So figure out the target column. Rules:

1. if we're on column zero, the new column is the ceiling (i.e. the rightmost column).

2. if we're on any other column besides zero, the new column is minus 1.

*/

var targetColumn = 0;

var globalSettings = $("form").data("globalSettings");

var oldCurrentColumn = globalSettings.currentColumn;

var currentRow = globalSettings.currentRow;

if (oldCurrentColumn == 0) {

targetColumn = globalSettings.columnCeiling;

}

else {

targetColumn = oldCurrentColumn - 1;

}

// set the current column to the new value,

// and reset the "previous" values so the selected style can be cleared

globalSettings.currentColumn = targetColumn;

globalSettings.previousCurrentRow = currentRow;

globalSettings.previousCurrentColumn = oldCurrentColumn;

$.SetPreviousAndCurrentCellStyles();

// for testing:

//$.DisplayRowColumnSettings();

}

$.HandleRightArrowKeypress = function() {

/*

We're going right; that means the row will remain unchanged.

So figure out the target column. Rules:

1. if we're at the rightmost column, the new column is zero (i.e. the leftmost column).

2. if we're on any other column besides the rightmost, the new column is plus 1.

*/

var targetColumn = 0;

var globalSettings = $("form").data("globalSettings");

var oldCurrentColumn = globalSettings.currentColumn;

var currentRow = globalSettings.currentRow;

var columnCeiling = globalSettings.columnCeiling;

if (oldCurrentColumn == columnCeiling) {

targetColumn = 0;

}

else {

targetColumn = oldCurrentColumn + 1;

}

// set the current column to the new value,

// and reset the "previous" values so the selected style can be cleared

globalSettings.currentColumn = targetColumn;

globalSettings.previousCurrentRow = currentRow;

globalSettings.previousCurrentColumn = oldCurrentColumn;

$.SetPreviousAndCurrentCellStyles();

// for testing:

//$.DisplayRowColumnSettings();

}

$.HandleUpArrowKeypress = function(e) {

/*

We're going up; that means the column will remain unchanged.

So figure out the target row. Rules:

1. if we're at the first (top) row (row zero), the new row will be the row ceiling

(i.e. the highest numbered row, i.e the bottom row).

2. if we're on any other row besides the topmost, the new row is minus 1.

*/

var targetRow = 0;

var globalSettings = $("form").data("globalSettings");

var oldCurrentRow = globalSettings.currentRow;

var currentColumn = globalSettings.currentColumn;

var rowCeiling = globalSettings.rowCeiling;

if (oldCurrentRow == 0) {

targetRow = rowCeiling;

}

else {

targetRow = oldCurrentRow - 1;

}

// set the current row to the new value,

// and reset the "previous" values so the selected style can be cleared

globalSettings.currentRow = targetRow;

globalSettings.previousCurrentRow = oldCurrentRow;

globalSettings.previousCurrentColumn = currentColumn;

$.SetPreviousAndCurrentCellStyles();

// for testing:

//$.DisplayRowColumnSettings();

// suppress the default scrolling behavior caused by the up/down arrows.

// the arrows on the review page will only be used to navigate radio buttons,

// not scroll up and down. Some people hate this idea but it makes sense here,

// because the default scrolling behavior (in my opinion) is confusing. See:

// http: //stackoverflow.com/questions/910724/is-it-possible-to-prevent-document-scrolling-when-arrow-keys-are-pressed

e.preventDefault();

return false;

}

$.HandleDownArrowKeypress = function(e) {

/*

We're going down; that means the column will remain unchanged.

So figure out the target row. Rules:

1. if we're at the last (bottom) row (the row ceiling), the new row will be row zero (top row).

2. if we're on any other row besides the bottom, the new row is plus 1.

*/

var targetRow = 0;

var globalSettings = $("form").data("globalSettings");

var oldCurrentRow = globalSettings.currentRow;

var currentColumn = globalSettings.currentColumn;

var rowCeiling = globalSettings.rowCeiling;

if (oldCurrentRow == rowCeiling) {

targetRow = 0;

}

else {

targetRow = oldCurrentRow + 1;

}

// set the current row to the new value,

// and reset the "previous" values so the selected style can be cleared

globalSettings.currentRow = targetRow;

globalSettings.previousCurrentRow = oldCurrentRow;

globalSettings.previousCurrentColumn = currentColumn;

$.SetPreviousAndCurrentCellStyles();

// for testing:

//$.DisplayRowColumnSettings();

// suppress the default scrolling behavior caused by the up/down arrows.

// the arrows on the review page will only be used to navigate radio buttons,

// not scroll up and down. Some people hate this idea but it makes sense here,

// because the default scrolling behavior (in my opinion) is confusing. See:

// http: //stackoverflow.com/questions/910724/is-it-possible-to-prevent-document-scrolling-when-arrow-keys-are-pressed

e.preventDefault();

return false;

}

$.HandleSpaceBarKeypress = function() {

$.ClickCurrentButton();

}

$.HandleEnterKeypress = function() {

$.ClickCurrentButton();

}

$.ClickCurrentButton = function() {

var currentButtonSelector = $.GetCurrentButtonSelectorString();

$(currentButtonSelector).click();

}

$.GetCurrentButtonSelectorString = function() {

var globalSettings = $("form").data("globalSettings");

var currentRow = globalSettings.currentRow;

var currentColumn = globalSettings.currentColumn;

var strCurrentRow = $.toFixedWidth(currentRow, 2, '0');

return "input[id=ctl00_mainContent_Repeater1_ctl" +

strCurrentRow + "_AnswerControl_AnswerButtonList_" + currentColumn + "]"

}

$.GetPreviousButtonSelectorString = function() {

var globalSettings = $("form").data("globalSettings");

var previousRow = globalSettings.previousCurrentRow;

var previousColumn = globalSettings.previousCurrentColumn;

var strPreviousRow = $.toFixedWidth(previousRow, 2, '0');

return "input[id=ctl00_mainContent_Repeater1_ctl" +

strPreviousRow + "_AnswerControl_AnswerButtonList_" + previousColumn + "]"

}

$.SetCurrentCellStyle = function() {

// called directly when the page is first loaded,

// because at that point we don't want to set the "previous" style

var currentButtonCellSelector = "td > " + $.GetCurrentButtonSelectorString();

var currentCell = $(currentButtonCellSelector);

currentCell.css('border-style', 'solid').css('border-width', '1px');

}

$.SetPreviousAndCurrentCellStyles = function() {

// clear the "previous" cell's border styles,

// and set the new "current" cell's border styles,

// to indicate that cell contains the "current" button

var previousButtonCellSelector = "td > " + $.GetPreviousButtonSelectorString();

var previousCell = $(previousButtonCellSelector);

previousCell.css('border-style', 'none');

// factored out so it can be called here or called directly in ready handler

$.SetCurrentCellStyle();

}

$.toFixedWidth = function(value, length, fill) {

// utility function from the book "jQuery in Action"

if (!fill) fill = '0';

var result = value.toString();

var padding = length - result.length;

if (padding < 0) {

result = result.substr(-padding);

}

else {

for (var n = 0; n < padding; n++) result = fill + result;

}

return result;

};

$.fn.SetRowAndColumnCeilings = function() {

return this.each(function() {

// sets up row/column ceilings based on values

// derived from the names of the radio buttons

var rowPos = $.GetRowPositionFromID(this.id);

var colPos = $.GetColumnPositionFromID(this.id);

var globalSettings = $("form").data("globalSettings");

/*

Dynamically set the global values for row and column ceiling

based on the row/column coordinates of the radio buttons being processed;

when the user is navigating with arrow keys, we need to know if

they've reached the right-most column, or the bottom row;

thus we store those values globally. This logic allows for the

possibility that rows or columns may be added in the future.

*/

// reset the row ceiling if necessary, based on the current row value

var currentRowCeiling = globalSettings.rowCeiling;

if (rowPos > currentRowCeiling) {

globalSettings.rowCeiling = rowPos;

}

// reset the column ceiling if necessary, based on the current column value

var currentColumnCeiling = globalSettings.columnCeiling;

if (colPos > currentColumnCeiling) {

globalSettings.columnCeiling = colPos;

}

});

}

})(jQuery)

Apologies for not putting up screenshots; I don’t have access to that site anymore, but if everything is configured correctly, this works. Maybe some variation of this idea might work in your situation. I hope you may find it useful.

Regards, –Dave

No comments:

Post a Comment