/**
 * jquery.aria-key-nav.js (ARIA keyboard navigation made easy)
 * @version 0.93 
 * @author Andrew Ramsden
 * @see http://irama.org/web/dhtml/aria/key-nav/
 * @license Common Public License Version 1.0 <http://www.opensource.org/licenses/cpl1.0.txt>
 * @requires jQuery (tested with version 1.3.1) <http://jquery.com/>
 * @requires jQuery jARIA plugin <http://outstandingelephant.com/jaria/>
 * @tested FF 3.6.3, Chrome 4, Safari 4, Opera 10.51, IE 6, 7 and 8.
 */

// 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; // ???
    DOM_VK_ESCAPE = 27;
    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,
    rememberActiveControl : true, // remember the active control when you tab away and return
    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 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
     * 
     * Focus is managaed with tabindex alone so that all browsers are supported.
     * @see http://www.w3.org/WAI/PF/aria-practices/#visualfocus
     * 
     * @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) {
        
        if (typeof options == 'undefined') {
            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)
            ;
        
        
        $(this)
            .addClass($.AKN.conf.controlsetClass)
            .data('controlSelector', controlSelector)
            .data('options', options)
            .each(initManageFocus)
        ;
        
        return $(this); /* facilitate chaining */
    };
    
    initManageFocus = function () {
        controlSelector = $(this).data('controlSelector');
        options = $(this).data('options');
        
        // Ensure each controlset has a unique id
            if ($(this).attr('id') == '') {
                $(this).attr('id', 'controlset-'+regionCount++);
            }
        
        // if no role set on container, set it based on supplied option or default
            if (typeof $(this).ariaRole() == 'undefined') {
                $(this).ariaRole(options.role);
            }
            $(this).attr('tabindex', -1);
            
        // 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)
                .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);
                        }
                        
                        $(this)
                            .attr('tabindex', -1)
                            .addClass($.AKN.conf.controlClass)
                        ;
                })
            ;
            
        // Determine the first element that would get focus
        // $.AKN.ORDER_REVERSE allows controls to be floated 'right', yet still controlled in the expected visual order.
            if (options.controlOrder == $.AKN.ORDER_NORMAL) {
                defaultControl = $(this).find(controlSelector+':first');
            } else {
                defaultControl = $(this).find(controlSelector+':last');
            }
            defaultControl.attr('tabindex', 0);
            
        
        // Capture key events
            $(this)
                .bind('keydown', handleKeyDownEvent)
                .bind('focusin', handleFocusInEvent)
            ;
        
    };
    
    handleKeyDownEvent = function (evt) {
        
        // init
            currentControl = $(evt.target);
            evtKeyCode = getKeyCode(evt);
            
        // If modifier keys are down, ignore key presses
            if (
                evt.ctrlKey ||
                evt.altKey ||
                evt.shiftKey ||
                evt.metaKey
            ) {
                return true; /* facilitate further bubbling */
            }
        
        // init
            containerEl = currentControl.closest('.'+$.AKN.conf.controlsetClass);
            options = containerEl.data('options');
        
        // Process list of keys to ignore
            if ($.inArray(evtKeyCode, options.ignoreKeys) != -1) {
                // Process key handlers anyway
                    processKeyHandlers(containerEl, options.keyHandlers, evt);
                return true; /* facilitate further bubbling */
            }
        
        // Not an ignored keycode, handle it here
            switch (evtKeyCode) {
                default:
                    // A different (untracked) key was pressed, just ignore it
                        return true; /* facilitate further bubbling */
                break;
                case DOM_VK_UP:
                case DOM_VK_LEFT:
                    direction = (options.controlOrder == $.AKN.ORDER_NORMAL) ? $.AKN.DIRECTION_PREV : $.AKN.DIRECTION_NEXT ;
                break;
                case DOM_VK_DOWN:
                case DOM_VK_RIGHT:
                    direction = (options.controlOrder == $.AKN.ORDER_NORMAL) ? $.AKN.DIRECTION_NEXT : $.AKN.DIRECTION_PREV ;
                break;
                case DOM_VK_HOME:
                    direction = (options.controlOrder == $.AKN.ORDER_NORMAL) ? $.AKN.DIRECTION_FIRST : $.AKN.DIRECTION_LAST ;
                break;
                case DOM_VK_END:
                    direction = (options.controlOrder == $.AKN.ORDER_NORMAL) ? $.AKN.DIRECTION_LAST : $.AKN.DIRECTION_FIRST ;
                break;
            }
        
        // arrow keys and home/end will scroll window, we don't want that (prevent default action)
            evt.preventDefault(); // Doesn't prevent scroll in Opera (10.00 and 10.53 tested)
        
        currentControl.focusNextControl(direction);
        
    };
    
    
    /**
     * If key handlers have been specified for this control, call them now.
     */
    processKeyHandlers = function (containerEl, keyHandlers, evt) {
        evtKeyCode = getKeyCode(evt);
        if (evtKeyCode in keyHandlers) {
            for (keyCode in keyHandlers) {
                if (keyCode == evtKeyCode) {
                    // Send through the controlset element as 'this'
                    // Send through the evt object so that evt.target can also be used
                        keyHandlers[keyCode].apply(containerEl.get(0), [evt]);
                }
            }
        }
    };
    
    
    /**
     * In case focus is initiated elsewhere, ensure this is the only
     * focused element.
     */
    handleFocusInEvent = function (evt) {
        
        focused = $(evt.target);
        containerEl = $(this);
        
        // if the container element was focused, shift focus to relevant child
            if (containerEl.attr('id') == focused.attr('id')) {
                // set focus to previously focused element
                containerEl.find('*[tabindex=0]').first().focus();
                return;
            }
        
        allControls = containerEl.find(containerEl.data('controlSelector'));
        
        allControls.not(focused).attr('tabindex', -1);
        focused.attr('tabindex', 0);
        
        
        if ($('body').data('akn-last-focused-controlset') != $(this).attr('id')) {
            
            // update last focused controlset
                $('body').data('akn-last-focused-controlset', $(this).attr('id'));
            
            // focus has entered the controlset, ensure focus is on appropriate control
                options = containerEl.data('options');
                
                if (options.rememberActiveControl) {
                    // last focused control still has tabindex="0", just return
                        return;
                } else {
                    
                    direction = (options.controlOrder == $.AKN.ORDER_NORMAL) ? $.AKN.DIRECTION_FIRST : $.AKN.DIRECTION_LAST ;
                    firstControl = $(focused).findNextControl(direction);
                    // if next is the same as current, do nothing, just return
                        if (focused.attr('id') == firstControl.attr('id')) {
                            return;
                        }
                    
                    // prevent the default focus action from occurring.
                        evt.preventDefault();
                    // Shift focus to where it should be
                        focused.shiftFocus(firstControl);
                }
        }
        
    };
    
    /**
     * Set focus on next control using "roving tabindex"
     * @see http://www.w3.org/WAI/PF/aria-practices/#kbd_general_within
     * 
     * @this the current control
     * @param direction The direction to move (next, prev, first or last)
     */
    $.fn.focusNextControl = function (direction) {
        return $(this).shiftFocus($(this).findNextControl(direction)); // faciliate chaining
    };
    
    $.fn.findNextControl = function (direction) {
        
        // find next control
            containerEl = $(this).closest('.'+$.AKN.conf.controlsetClass);
            
            allControls = containerEl.find(containerEl.data('controlSelector')).filter(':visible');
            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
                            return (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
                            return (currentControlIndex-1 >= 0)?allControls.eq(currentControlIndex-1):allControls.eq(allControls.size()-1);
                    break;
                    case $.AKN.DIRECTION_FIRST:
                        // jump straight to the first item
                            return allControls.eq(0);
                    break;
                    case $.AKN.DIRECTION_LAST:
                        // jump straight to the last item
                            return allControls.eq(allControls.size()-1);
                    break;
                }
    };
    
    $.fn.shiftFocus = function (toBeFocused) {
        // if next is the same as current, do nothing, just return
            if ($(this).attr('id') == toBeFocused.attr('id')) {
                return $(this); // faciliate chaining
            }
            
        // set focus
            toBeFocused
                .attr('tabindex', 0)
                .focus()
            ;
        
        // remove previous control from tab order
            $(this).attr('tabindex', -1);
        
        return $(this); // faciliate 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)
                        .addClass($.AKN.conf.regionClass)
                        .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).closest(parentRegionSelector));
        });
        
        return $(this); /* facilitate chaining */
    };
    
    /**
     * Get the keycode of an event
     */
    function getKeyCode(evt) {
        evt = evt || window.event;
        
        if (evt.keyCode) {
            return evt.keyCode;
        } else if (evt.which) {
            return evt.which;
        }
        return null;
    }
    
})(jQuery); /* end closure */
