/**
 * jquery.aria-key-nav.js (ARIA keyboard navigation made easy)
 * Currently, browsers that are known to be without ARIA capability get no keyboard navigation from this plugin.
 * 
 * @version 0.5
 * Changelog:
 *   * 0.5 Improved handling of ARIA roles
 * 
 * @author Andrew Ramsden
 * @see http://irama.org/web/dhtml/aria/key-nav/
 * @license GNU GENERAL PUBLIC LICENSE (GPL) <http://www.gnu.org/licenses/gpl.html>
 * @requires jQuery (tested with version 1.3.1) <http://jquery.com/>
 * @requires jQuery Utilities 2.3 <http://irama.org/web/dhtml/utilities/>
 * @requires jQuery jARIA plugin <http://outstandingelephant.com/jaria/>
 * @tested FF2 (success), FF3 (success), IE8 (success), Chrome (success), 
 *         Opera 9.5/9.61 (success: But strange text selection bug),
 *         Safari 3.0.2 (fail: no ARIA awareness, no blur), IE6/7 (fail: no ARIA awareness, no blur)
 */

/**
 * Determine if ARIA aupport is available
 *   Opera: Since 9.5 (@see http://www.opera.com/docs/changelogs/windows/950b1/)
 *   Safari: No support yet?
 *   IE: Since 8 (@see http://code.msdn.microsoft.com/Release/ProjectReleases.aspx?ProjectName=ie8whitepapers&ReleaseId=564)
 *   FF: Since 2 (Mozilla 1.8.1)
 *   Chrome: Is fine!
 *   Assume ARIA support if unknown browser
 */
$.browser.hasAria = (
	$.browser.opera && $.browser.version < '9.5' ||
	$.browser.safari  && $.browser.version <= '3.0.2' ||
	$.browser.msie && $.browser.version < '8' ||
	$.browser.mozilla && $.browser.version < '1.8.1'
)?false:true; // assume true if we don't know better


// CONSTANTS
	DOM_VK_END    = 35;
	DOM_VK_HOME   = 36;
	DOM_VK_LEFT   = 37;
	DOM_VK_UP     = 38;
	DOM_VK_RIGHT  = 39;
	DOM_VK_DOWN   = 40;
	DOM_VK_ENTER  = 14;
	DOM_VK_RETURN = 13; // ???
	jQuery.AKN = {
		DIRECTION_PREV  : 0,
		DIRECTION_NEXT  : 1,
		DIRECTION_FIRST : 2,
		DIRECTION_LAST  : 3,
		ORDER_NORMAL    : 1,
		ORDER_REVERSE   : 0
	};

/**
 * Default options, these can be overridden for each call to managefocus.
 */
jQuery.AKN.defaultOptions = {
	controlOrder   : jQuery.AKN.ORDER_NORMAL,
	ignoreKeys     : [], // Don't ignore any arrow keys by default. Example, to ignore left and right: [37,39]
	keyHandlers    : {}, // Don't add extra handlers by default. Example, to add separate handlers for left and right: {37:handleLeftKeyEvent,39:handleRightKeyEvent}
	role           : 'toolbar',
	controlRole    : 'button'
};

/**
 * Global configuration (these apply to every instance of controlset, etc...)
 * Adjust to suit your preferred markup here, these can't be overriden for each instance.
 */
jQuery.AKN.conf = {
	controlsetClass      : 'controlset',
	regionClass          : 'region',
	controlClass         : 'control',
	ariaFocusClass       : 'aria-focus' // given to the 'activedescendant' element so it can be styled (this is a requirement of ARIA best practice)
};
// start closure (protects variables from global scope)
(function($){
	
	// init
		regionCount = 0;
		controlCount = 0;
	
	
	/**
	 * Sets up keyboard navigation for a set of controls as per ARIA best practice.
	 * Treats the specified element as a control set (adding it to the tab order), 
	 * and the descendant controls selected by the first argument
	 * are then accessible via arrow keys.
	 * @see http://www.w3.org/TR/wai-aria-practices/#kbd_generalnav
	 * @param DOMNode this The container element that acts as "toolbar" for the controls.
	 * @param jQuerySelector controlSelector Individual controls to navigate between.
	 * @param Object options A set of options to override the $.AKN.defaultOptions. 
	 */
	$.fn.managefocus = function (controlSelector, /* optional */ options) {
		
		
		// Was a role sent for the controls? (controlRole)
			if (typeof options.controlRole == 'undefined' || options.controlRole == '') {
				// If not, try to guess an applicable role for the controls in the set (default '')
					switch ($.trim(options.role)) {
						case 'menubar':
						case 'toolbar':
							options.controlRole = 'button';
						break;
						case 'tablist':
							options.controlRole = 'tab';
						break;
						case 'menu':
							options.controlRole = 'menuitem';
						break;
						default:
							// don't set it, let the defaultOption come through
						break;
					}
			}
		
		// Merge runtime options with defaults
		// Note: The first argument sent to extend is an empty object to
		// prevent extend from overriding the default $.AKN.defaultOptions object.
			options = (typeof options == 'undefined')
				? $.AKN.defaultOptions
				: $.extend({}, $.AKN.defaultOptions, options)
			;
		
			
			
		// add controlset class
		// store the control selector as data for later use
		// listen for key events at container level (they should bubble up anyway)
			$(this)
				.addClass($.AKN.conf.controlsetClass)
				.data('controlSelector', controlSelector)
				.data('options', options) // Caveat: Duplicating options for each controlset could potentially be memory intesive
				.bind('keydown', handleKeyEvent)
			;
		
		// Protect ARIA-incapable browsers from the keyboard nav
		// FIXME: Replicate key nav behaviour for ARIA-incapable browsers
			if ( $.browser.hasAria ) {
				
				// add container to taborder
				// listen for focus and blur events at container level (they may bubble up from below)
					$(this)
						.attr('tabindex', 0)
						.bind('focus', handleContainerFocusEvent)
						.bind('blur', handleContainerBlurEvent)
					;
					
				// if no role set on container, set it based on supplied option or default
					if (typeof $(this).ariaRole() == 'undefined') {
						//$.debug('#'+$(this).parents('*[id]:first').attr('id')+' role = '+options.role);
						$(this).ariaRole(options.role);
					} else {
						//$.debug('#'+$(this).parents('*[id]:first').attr('id')+' role = '+$(this).ariaRole());
					}
				
				// Ensure each control is removed from tab order
				// Ensure all controls have a unique id
				// Add click handler to force focus event for non-aria UAs
					$(this).find(controlSelector)
						.attr('tabindex', -1)
						.each(function(){
							// if not id set, assign a unique id
								if ($(this).attr('id') == '') {
									controlCount++;
									$(this).attr('id', $.AKN.conf.controlClass+'-'+controlCount);
								}
							// if no role set on controls, set it based on supplied option or default
								if (typeof $(this).ariaRole() == 'undefined') {
									$(this).ariaRole(options.controlRole);
								}
						})
						.bind('click', handleControlClickEvent)
					;
				
				
			}
		
		// Determine and store element direction (normal or reverse) and the 
		// first element that would get focus (stored in lastFocussed data).
		// This allows controls to be floated 'right', yet still controlled in the expected visual order.
			if (options.controlOrder == $.AKN.ORDER_NORMAL) {
				$(this).each(function(){
					$(this).data('lastFocussed', $(this).find(controlSelector+':first').attr('id'));
				});
			} else {
				$(this).each(function(){
					$(this).data('lastFocussed', $(this).find(controlSelector+':last').attr('id'));
				});
			}
		
		return $(this); /* facilitate chaining */
	};
	
	
	handleKeyEvent = function (eventObj) {
		// If modifier keys are down, ignore key presses
			if (
				eventObj.altKey ||
				eventObj.ctrlKey ||
				eventObj.shiftKey
			) {
				return true; /* facilitate further bubbling */
			}
		

		//containerEl = $(eventObj.target);
		containerEl = $(this).is('.'+$.AKN.conf.controlsetClass)?$(this):$(this).parents('.'+$.AKN.conf.controlsetClass+':first');
		currentControl = containerEl.find('#'+containerEl.data('lastFocussed'));
		options = containerEl.data('options');
		
		
		
			
		
		// Protect ARIA-incapable browsers from the keyboard nav
		// FIXME: Replicate key nav behaviour for ARIA-incapable browsers
			if ( ! $.browser.hasAria ) {
				// Process key handlers anyway
					processKeyHandlers(options.keyHandlers, eventObj);
				return true; /* facilitate further bubbling */
			}
		
		// Process list of keys to ignore
			if ($.inArray(eventObj.keyCode, options.ignoreKeys) != -1) {
				// Process key handlers anyway
					processKeyHandlers(options.keyHandlers, eventObj);
				return true; /* facilitate further bubbling */
			}
		
		
		// Respond to arrow keys
			switch (eventObj.keyCode) {
				case DOM_VK_ENTER:
				case DOM_VK_RETURN:
					// trigger click action on element (also return, so no other action is taken)
						if (currentControl.triggerResult('click') != false) {
							// Links are not being followed???, follow them here
								if (currentControl.is('a')) {
									window.location = currentControl.attr('href');
								}
								return true;
						} else {
							return false;
						}
				break;
				case DOM_VK_UP:
				case DOM_VK_LEFT:
				
					eventObj.preventDefault(); // arrow keys will also scroll window, we don't want that
					
					if (options.controlOrder == $.AKN.ORDER_NORMAL) {
						// normal control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_PREV);
					} else {
						// reverse control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_NEXT);
					}
				break;
				
				case DOM_VK_DOWN:
				case DOM_VK_RIGHT:
				
					eventObj.preventDefault(); // arrow keys will also scroll window, we don't want that
					
					if (options.controlOrder == $.AKN.ORDER_NORMAL) {
						// normal control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_NEXT);
					} else {
						// reverse control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_PREV);
					}
				break;
				
				case DOM_VK_HOME:
					
					eventObj.preventDefault(); // home will jump window to top, we don't want that
					
					if (options.controlOrder == $.AKN.ORDER_NORMAL) {
						// normal control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_FIRST);
					} else {
						// reverse control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_LAST);
					}
				break;
				case DOM_VK_END:
					
					eventObj.preventDefault(); // end will jump window to bottom, we don't want that
					
					if (options.controlOrder == $.AKN.ORDER_NORMAL) {
						// normal control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_LAST);
					} else {
						// reverse control order
							nextControl = currentControl.getNextControl($.AKN.DIRECTION_FIRST);
					}
				break;
				
				default:
					// A different (untracked) key was pressed, just ignore it
						return true; /* facilitate further bubbling */
				break;
			}
		
		// Set focus to new element
			containerEl.removeAriaFocus('#'+containerEl.data('lastFocussed'));
			containerEl.setAriaFocus(nextControl);
		
		// Process extra key handlers now
			processKeyHandlers(options.keyHandlers, eventObj);
	};
	
	processKeyHandlers = function (keyHandlers, eventObj) {
		
		// target should always be the activedescendant
			if ($(eventObj.target).is('.'+$.AKN.conf.controlsetClass)) {
				controlSet = $(eventObj.target);
			} else {
				controlSet = $(eventObj.target).parents('.'+$.AKN.conf.controlsetClass+':first');
			}
			target = $('#'+controlSet.ariaState('activedescendant')).get();
			
		if (eventObj.keyCode in keyHandlers) {
			for (keyCode in keyHandlers) {
				if (keyCode == eventObj.keyCode) {
					keyHandlers[keyCode].apply(target, [eventObj]);
				}
			}
		}
	};
	
	/**
	 * Some browsers do not return focus to the container. (IE8, FF3)
	 */
	handleControlClickEvent = function (eventObj) {
		/*
		// Prompt container to set active descendant here.
			$(this).parents('.'+$.AKN.conf.controlsetClass+':first')
				.setAriaFocus(this)
			;
		// return focus to container
			$(this).parents('.'+$.AKN.conf.controlsetClass+':first').focus([eventObj, false]);
		*/
		$(this).parents('.'+$.AKN.conf.controlsetClass+':first')
			.data('lastFocussed',$(this).attr('id')) // pre-emptively set lastFocussed, then call focus() on controlset to do the rest
			.focus()
		;
		
		// return true to facilitate further bubbling
			return true;
	};
	
	handleContainerFocusEvent = function (eventObj) {
		
		// Ensure there are no new DOM elements added that are in the tab order
			$(this).find($(this).data('controlSelector')).attr('tabindex', -1);
		
		
		// Call scrollIntoView - as per ARIA best practice
			if (this.scrollIntoView) {
				//this.scrollIntoView(); // does bad things in IE8
			}
		
		
		if (this != eventObj.target) {		
			// event originated on a control (not on the container)
				$(this).setAriaFocus(eventObj.target);
			
		} else {
			// container was focussed, reinstate last active element
				$(this).setAriaFocus('#'+$(this).data('lastFocussed'));
		}
	};
	
	handleContainerBlurEvent = function (eventObj) {
		if (this != eventObj.target) {	
			// event originated on a control (not on the container)
				// do nothing
		} else {
			$(this).removeAriaFocus();
		}
	};




/* PLUGINS */
	
	$.fn.getNextControl = function (direction) {
		
		containerEl = $(this).parents('.'+$.AKN.conf.controlsetClass+':first');
		
		allControls = containerEl.find(containerEl.data('controlSelector'));
		currentControlIndex = allControls.index($(this));
		
		// find next element
			switch (direction) {
				default:
				case $.AKN.DIRECTION_NEXT:
					// navigation is circular, if there are no more elements on the end, wrap to the start
						nextControl = (currentControlIndex+1 < allControls.size())?allControls.eq(currentControlIndex+1):allControls.eq(0);
				break;
				case $.AKN.DIRECTION_PREV:
					// navigation is circular, if there are no more elements before, wrap to the last element
						nextControl = (currentControlIndex-1 >= 0)?allControls.eq(currentControlIndex-1):allControls.eq(allControls.size()-1);
				break;
				case $.AKN.DIRECTION_FIRST:
					// jump straight to the first item
						nextControl = allControls.eq(0);
				break;
				case $.AKN.DIRECTION_LAST:
					// jump straight to the last item
						nextControl = allControls.eq(allControls.size()-1);
				break;
			}
		
		return nextControl;
	}
	
	/**
	 * Trigger an event but return the result, instead of the jQuery object
	 * @version 1.1
	 * @author Andrew Ramsden <irama.org>
	 */
	$.fn.triggerResult  = function (eventType) {
		if (typeof eventType == 'undefined') {
			return null;
		}
		
		// can only capture the result for one element
		el = $(this).get(0);
		
		// browser capabilities check
			if (typeof document.createEvent != 'undefined') {
				// for standards-compliant browsers
				var evt = document.createEvent('MouseEvents');
				evt.initMouseEvent(eventType, true, true, window,
				0, 0, 0, 0, 0, false, false, false, false, 0, null);
				return el.dispatchEvent(evt);
			} else if (typeof document.createEventObject != 'undefined') {
				// for IE
				var eventObj = document.createEventObject();
				return el.fireEvent('on'+eventType,eventObj);
			} else {
				// Sorry, I haven't catered for you (whoever you might be), let me know!
					return null;
			}
	};
	
	$.fn.setAriaFocus = function (elementToFocus) {
		
		elementToFocus = $(elementToFocus).get(); // strip away jQuery obj if it exists, leave DOMNode only
		
		// remove ARIA class from previously focussed element
			$('#'+$(this).data('lastFocussed')).removeClass($.AKN.conf.ariaFocusClass);
		
		$(this)
			.ariaState('activedescendant', $(elementToFocus).attr('id'))
			.data('lastFocussed', $(elementToFocus).attr('id'))
		;
		
		$(elementToFocus).addClass($.AKN.conf.ariaFocusClass);
		
		// Call scrollIntoView - as per ARIA best practice
			if (elementToFocus.scrollIntoView) {
				elementToFocus.scrollIntoView();
			}
		
		return $(this); /* facilitate chaining */
	}
	
	$.fn.removeAriaFocus = function () {
		$('#'+$(this).ariaState('activedescendant')).removeClass($.AKN.conf.ariaFocusClass);
		$(this).ariaState('activedescendant','');
		
		return $(this); /* facilitate chaining */
	}
	
	/**
	 * Establishes a relationship between a controlset and the page regions controlled by the controlset.
	 * As per ARIA controls property.
	 * @see http://www.w3.org/TR/wai-aria/#controls

	 */
	$.fn.controls = function (regionControlledSelector) {
		
		$(this).each(function() {
			elementList = '';
			
			// for each element matched (controlled) add to controls property
				$(regionControlledSelector).each(function(){
					if ($(this).attr('id') == '') {
						regionCount++;
						$(this).attr('id', $.AKN.conf.regionClass+'-'+regionCount);
					}
					
					elementList += ' '+$(this).attr('id');
				});
				
				if (elementList != '') {
					$(this).ariaState('controls', $.trim(elementList));
				}
		});
		
		return $(this); /* facilitate chaining */
	};
	
	/**
	 * Establishes a relationship between a controlset and a parent region controlled by the controlset.
	 * As per ARIA controls property.
	 * @see http://www.w3.org/TR/wai-aria/#controls
	 */
	$.fn.controlsParent = function (parentRegionSelector) {
		$(this).each(function() {
			$(this).controls($(this).parents(parentRegionSelector+':lt(1)'));
		});
		
		return $(this); /* facilitate chaining */
	};
	
})(jQuery); /* end closure */