Fix for Select Elements with Inaccessible Onchange Events

BACKGROUND

Onchange Event Handler Accessibility Issues

Many tools in Sakai 2 make use of onchange event handlers on select elements (drop-down lists/menus) to automatically process the change of a setting from a drop down menu without requiring the user to press a separate submit button. The result for mouse users is a more efficient and responsive UI interaction and it simplifies the design of the controls layout. Unfortunately it can lead to some accessibility issues for users who are keyboard users or users of adaptive technology that emulate keyboard interactions. Certain browsers (IE is one) will fire the onchange event when the up/down cursor keys are used to scroll to the next/previous option. If the onchange event handler changes focus away from the control the user will be prevented from exploring all of the options in the select menu (they'll probably only be able to access options one off from the default selected value).

In IE there are key combinations which allow exploration of the options without triggering the onchange event (using Alt+Down Arrow to open the list). Unfortunately many users don't know this key combination or are not in the habit of using it. Some alternate input adaptive technologies don't support the Alt+Down arrow key combination.

This issue relates to WCAG 2.0 Success Criterion 3.2.2:

3.2.2 On Input: Changing the setting of any user interface component does not automatically cause a change of context unless the user has been advised of the behavior before using the component. (Level A) - http://www.w3.org/TR/2008/REC-WCAG20-20081211/#consistent-behavior-unpredictable-change

WCAG 2.0 F37 goes further to explain this issue:

"Failure of Success Criterion 3.2.2 due to launching a new window without prior warning when the status of a radio button, check box or select list is changed" - http://www.w3.org/TR/2008/NOTE-WCAG20-TECHS-20081211/F37

The University of Illinois at Urbana/Champaign Campus Information Technologies HTML/XHTML Accessibility Best Practices discusses onChange event accessibility issues more practically here: http://cita.disability.uiuc.edu/html-best-practices/auto/onchange.php

Accessibility Issues With the Use of blur() in Onchange Event Handlers

Unfortunately there is a programming practice that causes a very frustrating keyboard usability issue even in browsers where the interactions that trigger the select element's onchange handler are more accessible (even if the user knows the Alt+Down Arrow key combination). Many select elements in Sakai have blur() in their onchange event handler. The result is that as soon as the selected item is changed, focus leaves the select control. What happens to the focus is browser dependent. In some focus goes to the parent element; in others no element is given focus and the next tab starts over at the front of the tab order or picks back up with the next element in the tab order.

onchange="blur()" was a recommended error proofing / usability fix for mouse users who use the scroll wheel to scroll a Web page. In older browsers, if the select element had focus spinning the scroll wheel would change the setting. By using blur() in the onchange handler, once the select menu option was set, focus is immediately shifted off of the element preventing the scroll wheel from accidentally changing the value when the user attempted to use the wheel to scroll the page.

Unfortunately this fix makes it very frustrating for a keyboard user to fill out forms as for each select element they change the setting of forces them to start navigating the page over again. This is very confusing for blind users who won't immediately realize that the focus has changed inappropriately.

This use of blur() in onchange event handlers could be considered a failure to meet WCAG 2.0 SC 2.4.3:

2.4.3: Focus Order: If a Web page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability. (Level A) - http://www.w3.org/TR/2008/REC-WCAG20-20081211/#navigation-mechanisms-focus-order

Summary of Browser Behavior

OS

Browser

Default select+onchange behavior is accessible

Hot Key(s) that Open Menu

Mousewheel Changes Setting

Next Tab After Blur() Focus Behavior

OS X

Firefox 3.6.8

Yes

Option+Up, Option+Down

No

Picks up after last tabbed to control, or starts over if tab wasn't previously used

OS X

Safari 5.0

Yes

Up / Down keys

No

Focus Order Starts Over

XP

IE 6

No

Alt+Up, Alt+Down

Yes

Focus Order Starts Over

XP

IE 7

No

Alt+Up, Alt+Down

Yes

Focus Order Starts Over

XP

IE 8

No

Alt+Up, Alt+Down

Yes

Focus Order Starts Over

Win7

IE 9 Preview 3

No

Alt+Up, Alt+Down

Yes

Focus Order Starts Over

XP

Firefox 2.0

Yes

Alt+Up, Alt+Down

No

Back to Same Control

XP

Firefox 3.0

Yes

Alt+Up, Alt+Down

No

Back to Same Control

XP

Firefox 3.5

Yes

Alt+Up, Alt+Down

No

Back to Same Control

XP

Firefox 3.6.8

Yes

Alt+Up, Alt+Down

No

Next Control in Tab Order

XP

Opera 10.5

No - Any change submits entire form

Alt+Down

No

Focus Order Starts Over

XP

Opera 10.6

No

Alt+Down

No

Focus Order Starts Over

Existing Fix

In some tools, there is hidden text that screen reader users can read located just before the select element that reminds them to open the select menu using Alt+Down. The problem with this is that the message is unavailable to sighted non-mouse users.

Possible Fixes

  1. Remove blur() from onchange events and replace it with mousewheel="return false;" to disable the mousewheel on select elements in IE. This would require simple changes to most tools. This would improve the situation for users using browsers other than IE.
  2. Use JavaScript to attach an interaction handler to select elements that makes them as accessible as possible by blocking onchange events that fire when arrow keys are pressed and attempts to filter out onchange events that contain simply "blur()". The interaction handler (code follows) Brian Richwine worked up isn't just a few lines due to the need to handle many keyboard/mouse IU interaction possibilities. (The following code is attached to this page.)
Testing a11ySelectHandler.js
Browsers
  • XP: IE 6, IE 7, IE 8
  • Win7: IE 8, IE 9 (Preview 3)
Test Suite

A zip file containing a11ySelectHandler.js, jQuery, and an HTML file that loads it is attached. Please see select.a11y.zip

a11ySelectHandler.js
a11ySelectHandler.js
/**********************************************************************************
 * $URL: $
 * $Id: $
 ***********************************************************************************
 *
 * Copyright (c) 2010 The Sakai Foundation
 *
 * Licensed under the Educational Community License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *       http://www.osedu.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 *********************************************************************************/
/*
 * a11ySelectHandler.js -- Brian Richwine, Indiana University
 * Version 1.0, 2010.October.15
 * Enhanced version of code by Cameron Adams from 19.October.2004 as found on at:
 * http://themaninblue.com/writing/perspective/2004/10/19/
 *
 * Interaction manager for select elements with onchange event handlers that
 * attempts to ensure their accessibility for keyboard only users using IE. 
 * In Internet Explorer, the onchange event is fired each time the user
 * uses the up/down arrow keys to scroll through the various options of a 
 * select element. When the select element includes an onchange event handler
 * that changes the context (i.e.: submits a form, etc.), then the keyboard user
 * is prevented from exploring all of the options.
 *
 * This handler models its user interaction behavior after Mozilla Firefox 
 * 3.6 on the PC.
 *
 * Call with the following code from the bottom of the HTML body:
 *   <!--[if IE]>
 *     <script type="text/javascript" defer="defer" src="/library/js/a11ySelectHandler.js"></script>
 *   <![endif]-->
 * Notes: 
 *   - Be sure to load jQuery first!
 *   - This routine is needed only for Internet Explorer. Other supported web browsers (Safari, 
 *     Chrome, and Firefox) handle keyboard interactions with select elements in an accessible manner. 
 *   - This code adds an "a11ySelect" object to each select element that has an onchange event handler.
 *   - This code assumes existing onchange events are all DOM Level 0 or 1 (only one event handler allowed)
 *      DOM Level 0 events are assigned in the HTML like this: 
 *          onchange="alert('hello!');"
 *      DOM Level 1 events are assigned like this: 
 *          document.getElementById('selectMenu1').onchange = function() {
 *            alert('Hello!');
 *          }
 *
 *      This code has not been tested where DOM Level 2 events are handling the select element's onchange event.
 *      DOM Level 2 events are assigned like this:
 *          document.getElementById('myButton').addEventListener( 'click', function(){
 *             alert('Hello!');
 *           }, false); 
 *
 *
 * This code and the results of interactions testing performed on 11.August.2010 are
 * documented on the Sakai Accessibility Working Group confluence space. 
 *
 * Tested against:
 *  XP: IE 6, IE 7, IE 8, 
 *  Win7: IE 9 (Preview 3)
 */

(function ($) {
    if ($.browser.msie) {
	    $(document).ready(function () { // Scan document and install handler once document is loaded and ready
            $("select").each(function (index, objElement) { // "select[onchange]" didn't work, is Sakai using an old version of jQuery?
                if (objElement && objElement.onchange) {
                    objElement.a11ySelect = {
                        allowOnchangeEvent : false,
                        enterKeyWasPressed : false,
                        initValue : null,
                        lastInteractionWasAClick : true,
                        allowPossibleOpenMenuChange : false,
                        oSelect : null,
                        origOnfocus : null,
                        origOnchange : null,
                        origOnkeydown : null,
                        origOnclick : null,
                        origOnblur : null,
                        handleChanged : function (ob) {
                            var oSelect = (ob && ob.a11ySelect) ? ob : this,
                                a11ySelect = oSelect.a11ySelect;
                    
                            if (!a11ySelect.allowOnchangeEvent && !a11ySelect.allowPossibleOpenMenuChange) {
                                return false; // Abort calling the original onchange event handler
                            }
                            a11ySelect.allowOnchangeEvent = false; // Prevent redundant calls to orig onchange event
                            a11ySelect.initValue = a11ySelect.oSelect.value; // Store current value as init since calling onchange

                            if (a11ySelect.origOnchange) {
                                return a11ySelect.origOnchange(ob);
                            }
                            return false;
                        },
                        handleClick : function (e) {
                            // Handles clicks on menus opened by keyboard (alt+down arrow)
                            if (this.value !== this.a11ySelect.initValue && !this.a11ySelect.lastInteractionWasAClick) {
                                this.a11ySelect.allowOnchangeEvent = true;
                                this.a11ySelect.handleChanged(this);
                            }

                            this.a11ySelect.lastInteractionWasAClick = true;
                            this.a11ySelect.allowOnchangeEvent = true;
                            this.a11ySelect.enterKeyWasPressed = false;
                            this.a11ySelect.allowPossibleOpenMenuChange = false;

                            if (this.a11ySelect.origOnclick) {
                                return this.a11ySelect.origOnclick(e);
                            }
                        },
                        handleFocus : function (e) {
                            // Reset everything...
                            this.a11ySelect.initValue = this.value;
                            this.a11ySelect.allowOnchangeEvent = false;
                            this.a11ySelect.enterKeyWasPressed = false;
                            this.a11ySelect.lastInteractionWasAClick = true;
                            this.a11ySelect.allowPossibleOpenMenuChange = false;

                            if (this.a11ySelect.origOnfocus) {
                                return this.a11ySelect.origOnfocus(e);
                            }
                            return true;
                        },
                        handleBlur : function (e) {
                            // Call orig onChange event when focus is lost if needed
                            if (this.value !== this.a11ySelect.initValue && !this.a11ySelect.enterKeyWasPressed) {
                                this.a11ySelect.allowOnchangeEvent = true;
                                this.a11ySelect.handleChanged(this); 
                            }
                        
                            if (this.a11ySelect.origOnblur) {
                                return this.a11ySelect.origOnblur(e);
                            }

                            return true;
                        },
                        handleKeydown : function (e) {
                            var evt = (e) ? e : window.event,
                                keyCodeEnter = 13,
                                keyCodeEsc = 27;

                            this.a11ySelect.lastInteractionWasAClick = false;
                            this.a11ySelect.allowPossibleOpenMenuChange = false;

                            if (evt.keyCode === keyCodeEnter && this.value !== this.a11ySelect.initValue) {
                                this.a11ySelect.allowOnchangeEvent = true;
                                this.a11ySelect.enterKeyWasPressed = true;
                                this.a11ySelect.handleChanged(this);
                            }
                            else if (evt.keyCode === keyCodeEnter) {
                                // Expanded select menus (alt+down arrow key or by click) don't update value until after keypress event
                                // This allows menus opened by a mouse, but set with an enter key to work
                                this.a11ySelect.allowPossibleOpenMenuChange = true;
                                this.a11ySelect.enterKeyWasPressed = true;
                            }
                            else {
                                this.a11ySelect.enterKeyWasPressed = false;
                            }

                            if (evt.keyCode === keyCodeEsc) {
                                // restore original value when escape is pressed
								this.value = this.a11ySelect.initValue; 
                            }

                            this.a11ySelect.allowOnchangeEvent = false;
                            return true;
                        },
                        init : function (oSel) {
	                        // Keep a reference to the select element object
                            oSel.a11ySelect.oSelect = oSel;
                    
                            oSel.a11ySelect.initValue = oSel.value;
                            oSel.a11ySelect.allowOnchangeEvent = false;
                            oSel.a11ySelect.enterKeyWasPressed = false;
                            oSel.a11ySelect.allowPossibleOpenMenuChange = false;

                            // Store original handlers
                            oSel.a11ySelect.origOnfocus = oSel.onfocus;
                            oSel.a11ySelect.origOnkeydown = oSel.onkeydown;
                            oSel.a11ySelect.origOnclick = oSel.onclick;
                            oSel.a11ySelect.origOnchange = oSel.onchange;
                            oSel.a11ySelect.origOnblur = oSel.onblur;

                            // Install handlers
                            oSel.onfocus = oSel.a11ySelect.handleFocus;
                            oSel.onkeydown = oSel.a11ySelect.handleKeydown;
                            oSel.onclick = oSel.a11ySelect.handleClick;
                            oSel.onchange = oSel.a11ySelect.handleChanged;
                            oSel.onblur = oSel.a11ySelect.handleBlur;
                    
                            $(oSel).css("outline", "1px dotted red");
                        }
                    };
                    objElement.a11ySelect.init(objElement);   
                }           
            });
        });
    }
})(jQuery);