LCOV - code coverage report
Current view: top level - src - ariaMenu.js (source / functions) Hit Total Coverage
Test: lcov.info Lines: 83 84 98.8 %
Date: 2013-12-02 Functions: 27 27 100.0 %

          Line data    Source code
       1             : /** Copyright (c) 2013 Jan Nicklas Released under MIT license */
       2             : 
       3             : // Closure compiler requires ['name'] notation
       4             : // http://closuretools.blogspot.de/2011/01/property-by-any-other-name-part-1.html
       5             : /* jshint sub:true */
       6             : 
       7             : /* global jQuery: true */
       8             : 
       9          15 : (function ($) {
      10          15 :   'use strict';
      11             :   // @@ start @@ //
      12             : 
      13             :   /**
      14             :    * Returns true if the browser supports touch events
      15             :    *
      16             :    * @return {boolean}
      17             :    */
      18          15 :   function supportsTouch() {
      19          15 :     return 'ontouchstart' in window || 'onmsgesturechange' in window;
      20             :   }
      21             : 
      22             :   /**
      23             :    * The menu class
      24             :    *
      25             :    * @param {HTMLElement} item
      26             :    * @param {Array} [options]
      27             :    * @constructor
      28             :    */
      29          15 :   function AriaMenu(item, options) {
      30          15 :     this.settings = $.extend(this.defaults, options);
      31          15 :     this.$elem = $(item);
      32          15 :     this.init();
      33             :   }
      34             : 
      35          15 :   AriaMenu.prototype = {
      36             :     /* Default settings */
      37             :     // Single quoted keys are required for the google closure compiler:
      38             :     // http://closuretools.blogspot.de/2011/01/property-by-any-other-name-part-1.html
      39             :     defaults: {
      40             :       'focusClass': 'menuitem-focus',
      41             :       'visibleMenuClass': 'show-menu',
      42             :       'closeDelay': 100
      43             :     },
      44             : 
      45             :     events: {
      46             : 
      47             :       /**
      48             :        * Triggered if the users touches a link
      49             :        * @param {jQuery.event=} event
      50             :        * @this {HTMLElement} link
      51             :        */
      52             :       linkTouch: function (event) {
      53             :         // prevent the link execution
      54           2 :         event.preventDefault();
      55           2 :         var settings = event.data.settings;
      56             :         // Get the links parent element
      57           2 :         var $touchedListElement = $(this).parent();
      58             :         // If a sub menu can be found and the user didn't tap this list item item just before
      59           2 :         if ($touchedListElement.find('>ul').length && !$touchedListElement.hasClass(settings['focusClass'])) {
      60             :           // open the sub menu
      61           2 :           $(this).focus();
      62             :           // prevent phantom clicks
      63           2 :           preventPhantomClicks($(this));
      64             :         } else {
      65             :           // Fix IOS-double click issue
      66             :           // http://stackoverflow.com/questions/3038898/ipad-iphone-hover-problem-causes-the-user-to-double-click-a-link
      67           0 :           this.click();
      68             :         }
      69             :       },
      70             : 
      71             :       /* Triggered if the user moves his mouse over a list item */
      72             :       listItemMouseOver: function () {
      73          32 :         $(this).focus();
      74             :       },
      75             : 
      76             :       /* Triggered if the user moves his mouse away from a list item */
      77             :       listItemMouseOut: function () {
      78          19 :         if ($(this).is(':focus')) {
      79          19 :           $(this).blur();
      80             :         }
      81             :       },
      82             :       /**
      83             :        * Triggered if a list item receives focus
      84             :        * @param {jQuery.event=} event
      85             :        * @this {HTMLElement}
      86             :        */
      87             :       listItemFocus: function (event) {
      88         290 :         var settings = event.data.settings;
      89             :         // Stop hiding this element (clear timeout from listItemBlur)
      90         290 :         debounce(this);
      91             : 
      92             :         // Show the sub menus
      93         290 :         $(this)
      94             :           .addClass(settings['focusClass'])
      95             :           .find('>ul')
      96             :           .addClass(settings['visibleMenuClass']);
      97             :       },
      98             : 
      99             :       /**
     100             :        * Triggered if the focus is lost
     101             :        * @param {jQuery.event=} event
     102             :        * @this {HTMLElement}
     103             :        */
     104             :       listItemBlur: function (event) {
     105         263 :         var settings = event.data.settings;
     106             : 
     107             :         // Wait for a short moment and hide the sub menus
     108         263 :         debounce(this, function () {
     109          70 :           $(this).removeClass(settings['focusClass'])
     110             :             .find('>ul')
     111             :             .removeClass(settings['visibleMenuClass']);
     112             :         }, settings['closeDelay']);
     113             :       },
     114             : 
     115             :       /**
     116             :        * Triggered if a key event bubbles to a root menu list item
     117             :        * @param {jQuery.event=} event
     118             :        */
     119             :       keyDown: function (event) {
     120          77 :         var _this = event.data;
     121             : 
     122             :         // Escape pressed:
     123          77 :         if (event.which === 27) {
     124           7 :           event.preventDefault();
     125             :           // Hide the menu
     126           7 :           _this.closeMenu();
     127             :         }
     128             :         // Arrow keys pressed:
     129          70 :         else if (event.which >= 37 && event.which <= 40) {
     130          28 :           _this.events.arrowKeyDown.apply(this, arguments);
     131             :         }
     132             :       },
     133             : 
     134             :       /**
     135             :        * Triggered if an arrow key event bubbles to a root menu list item
     136             :        * @param {jQuery.event=} event
     137             :        */
     138             :       arrowKeyDown: function (event) {
     139          28 :         var _this = event.data;
     140             : 
     141             :         // Usually the event target is the focused link - so we pick the
     142             :         // parent to get the active listElement
     143          28 :         var $focusedListElement = $(event.target).closest('li'),
     144             :         // Get the focused menu element
     145             :           $focusedMenu = $focusedListElement.closest('ul');
     146             : 
     147             :         // Get the sub menu (if one exists)
     148          28 :         var $subMenu = $focusedListElement.find('>ul');
     149             : 
     150             :         //  Get the parent element of the focused element (if one exists)
     151          28 :         var $parentListElement = ($focusedListElement[0] === this) ? $() : $focusedMenu.closest('li'),
     152             :         // Get the parent menu of the focused element (if one exists)
     153             :           $parentMenu = $parentListElement.parent();
     154             : 
     155             :         // Create a virtual cursor with the position of the focused element
     156          28 :         var virtualCursor = new VirtualCursor();
     157             :         // Move the cursor over the current focused element
     158          28 :         virtualCursor.moveOver($focusedListElement);
     159          28 :         virtualCursor.moveRelative(event.which, $focusedListElement.width(), $focusedListElement.height());
     160             : 
     161             :         // Select the element below the virtual cursor
     162          28 :         var $selectedListElement = $(virtualCursor.getElementBelowCursor('li')),
     163             :           selectedMenu = $selectedListElement.parent()[0] || false;
     164             : 
     165             :         // Check if the virtual cursor selected a sibling menu item
     166             :         // or if the virtual cursor selected a sub menu
     167          28 :         if ($focusedMenu[0] === selectedMenu || $subMenu[0] === selectedMenu) {
     168          21 :           if (_this.selectListElement($selectedListElement)) {
     169          21 :             event.preventDefault();
     170             :           }
     171             :         }
     172             :         // Check if the virtual cursor selected a parent menu
     173           7 :         else if ($parentMenu[0] === selectedMenu) {
     174             :           // Select the parent list element
     175           7 :           if (_this.selectListElement($parentListElement)) {
     176           7 :             event.preventDefault();
     177             :           }
     178             :         }
     179             :       }
     180             :     },
     181             : 
     182             :     /**
     183             :      * Plugin initialization
     184             :      */
     185             :     init: function () {
     186          15 :       this.setAriaRoles();
     187          15 :       this.$elem
     188             :         // Listen to mouse and keyboard events
     189             :         .on('focusin', 'li', this, this.events.listItemFocus)
     190             :         .on('focusout', 'li', this, this.events.listItemBlur)
     191             :         .on('mouseover', 'a', this, this.events.listItemMouseOver)
     192             :         .on('mouseout', 'a', this, this.events.listItemMouseOut)
     193             :         .on('keydown', '>li', this, this.events.keyDown)
     194             :         .on('touchend', 'a', this, this.events.linkTouch)
     195             :         // Disable the css fallback
     196             :         .removeClass('css-fallback')
     197             :         // Add aria-menu class
     198             :         .addClass('aria-menu')
     199             :         // Touch support
     200             :         .addClass((supportsTouch() ? 'has' : 'no') + '-touch');
     201             : 
     202             :     },
     203             : 
     204             :     /**
     205             :      * Helper function
     206             :      */
     207             :     find: function (selector) {
     208          30 :       return this.$elem.find(selector);
     209             :     },
     210             : 
     211             :     /**
     212             :      * Sets the focus to the first visible anchor child in this list element.
     213             :      * The element might be an `ul` or a `li` element containing an `a` anchor.
     214             :      *
     215             :      * @param {jQuery} $element
     216             :      * @returns {boolean}
     217             :      */
     218             :     selectListElement: function ($element) {
     219          28 :       return $element
     220             :         // Find the `a` tag starting from the `ul` or `li`:
     221             :         .find('>li>a, >a')
     222             :         // make sure that the anchor is visible
     223             :         .filter(':visible:first')
     224             :         // set the focus
     225             :         .focus()
     226             :         // as the filter selects only the first element the length
     227             :         // can be either 0 or 1
     228             :         .length === 1;
     229             :     },
     230             : 
     231             : 
     232             :     /**
     233             :      * Add Aria roles and poperties
     234             :      * http://http://www.w3.org/TR/wai-aria/
     235             :      */
     236             :     setAriaRoles: function () {
     237             :       // Add ARIA role to menu bar
     238             :       // http://www.w3.org/TR/wai-aria/roles#menubar
     239          15 :       this.$elem.attr('role', 'menubar');
     240             : 
     241             :       // Add ARIA role to menu items
     242             :       // http://www.w3.org/TR/wai-aria/roles#menuitem
     243          15 :       this.find('li').attr('role', 'menuitem');
     244             : 
     245          15 :       this.find('a+ul')
     246             :         // Adding aria-haspopup for appropriate items
     247             :         // http://www.w3.org/TR/wai-aria/states_and_properties#aria-haspopup
     248             :         .each(function () {
     249          90 :           $(this).prev('a').attr('aria-haspopup', 'true');
     250             :         });
     251             :     },
     252             : 
     253             :     /**
     254             :      * Closes the menu by removing the focus
     255             :      */
     256             :     closeMenu: function () {
     257           7 :       this.$elem.find(':focus').blur();
     258             :     }
     259             :   };
     260             : 
     261             :   /**
     262             :    * A virtual mouse cursor
     263             :    * which helps to support keyboard navigation
     264             :    *
     265             :    * @constructor
     266             :    */
     267          15 :   function VirtualCursor() {
     268          28 :     this.left = 0;
     269          28 :     this.top = 0;
     270             :   }
     271             : 
     272          15 :   VirtualCursor.prototype = {
     273             :     /**
     274             :      * Move the virtual cursor over the center of the given target
     275             :      *
     276             :      * @param {HTMLElement|jQuery} target
     277             :      */
     278             :     moveOver: function (target) {
     279          28 :       var $target = $(target),
     280             :         $document = $(document);
     281             : 
     282             :       // Set left and top value
     283          28 :       $.extend(this, $target.offset());
     284             : 
     285             :       // Move virtual cursor to the middle of the focused element
     286             :       // relative to the current scroll position
     287          28 :       this.top += 0.5 * $target.height() - $document.scrollTop();
     288          28 :       this.left += 0.5 * $target.width() - $document.scrollLeft();
     289             :     },
     290             : 
     291             :     /**
     292             :      * Moves the curse relative to the last position
     293             :      *
     294             :      * @param direction {number} 37|38|39|40 Direction constant - as in the key code map
     295             :      * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent#Virtual_key_codes
     296             :      * DOM_VK_LEFT   0x25 (37)  Left arrow.
     297             :      * DOM_VK_UP     0x26 (38)  Up arrow.
     298             :      * DOM_VK_RIGHT  0x27 (39)  Right arrow.
     299             :      * DOM_VK_DOWN   0x28 (40)  Down arrow.
     300             :      *
     301             :      * @param x {number}
     302             :      * @param y {number}
     303             :      */
     304             :     moveRelative: function (direction, x, y) {
     305             :       // {left} or {right} key pressed
     306          28 :       if (direction === 37 || direction === 39) {
     307             :         // Move the virtual cursor horizontally
     308             :         // event.which - 38 = -1 || +1
     309          14 :         this.left += (direction - 38) * x;
     310             :       }
     311             :       // {up} or {bottom} key pressed
     312             :       else {
     313             :         // Move the virtual cursor vertically
     314             :         // event.which - 39 = -1 || +1
     315          14 :         this.top += (direction - 39) * y;
     316             :       }
     317             :     },
     318             : 
     319             :     /**
     320             :      * Return the element at the current courser position
     321             :      *
     322             :      * @param {string} [selector]
     323             :      * @returns {jQuery}
     324             :      */
     325             :     getElementBelowCursor: function (selector) {
     326          28 :       var $element = $(document.elementFromPoint(this.left, this.top));
     327          28 :       if (selector) {
     328          28 :         $element = $element.closest(selector);
     329             :       }
     330          28 :       return $element;
     331             :     }
     332             :   };
     333             : 
     334             :   /**
     335             :    * Execute a function once per element after a given delay
     336             :    *
     337             :    * @param {HTMLElement|jQuery} element
     338             :    * @param {function()=} [callback]
     339             :    * @param {number=} [delay]
     340             :    */
     341          15 :   function debounce(element, callback, delay) {
     342         553 :     clearTimeout(Number($(element).data('am-delay')));
     343         553 :     if (callback && delay) {
     344         263 :       $(element).data('am-delay', setTimeout($.proxy(callback, element), delay));
     345             :     }
     346             :   }
     347             : 
     348             :   /**
     349             :    * Prevent Android from triggering buggy phantom clicks
     350             :    * http://stackoverflow.com/questions/2987706/touchend-event-doesnt-work-on-android
     351             :    * http://stackoverflow.com/questions/17352865/preventdefault-not-stopping-mouseup-event-on-android/19717278
     352             :    * http://jsfiddle.net/FjuHu/6/
     353             :    *
     354             :    * @param {jQuery} $element
     355             :    */
     356          15 :   function preventPhantomClicks($element) {
     357             :     /**
     358             :      * Click catcher
     359             :      * @param {!jQuery.event=} event
     360             :      */
     361           2 :     function preventHandler(event) {
     362           2 :       event.preventDefault();
     363             :     }
     364             : 
     365             :     // catch all events for the next 350ms
     366           2 :     $element.on('click', preventHandler);
     367           2 :     setTimeout(function () {
     368           2 :       $element.off('click', preventHandler);
     369             :     }, 350);
     370             :   }
     371             : 
     372             : 
     373             :   // jQuery plugin interface
     374             :   // Use quoted notation for the closure compiler ADVANCED_OPTIMIZATIONS mode
     375          15 :   $['fn']['ariaMenu'] = function (opt) {
     376          15 :     return this.each(function () {
     377          15 :       var item = $(this), instance = item.data('AriaMenu');
     378          15 :       if (!instance) {
     379             :         // create plugin instance and save it in data
     380          15 :         item.data('AriaMenu', new AriaMenu(this, opt));
     381             :       }
     382             :     });
     383             :   };
     384             : 
     385             :   // @@ end @@ //
     386             : }(jQuery));

Generated by: LCOV version 1.10