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));
|