| | 1 | /** |
| | 2 | * menu-aim is a jQuery plugin for dropdown menus that can differentiate |
| | 3 | * between a user trying hover over a dropdown item vs trying to navigate into |
| | 4 | * a submenu's contents. |
| | 5 | * |
| | 6 | * menu-aim assumes that you have are using a menu with submenus that expand |
| | 7 | * to the menu's right. It will fire events when the user's mouse enters a new |
| | 8 | * dropdown item *and* when that item is being intentionally hovered over. |
| | 9 | * |
| | 10 | * __________________________ |
| | 11 | * | Monkeys >| Gorilla | |
| | 12 | * | Gorillas >| Content | |
| | 13 | * | Chimps >| Here | |
| | 14 | * |___________|____________| |
| | 15 | * |
| | 16 | * In the above example, "Gorillas" is selected and its submenu content is |
| | 17 | * being shown on the right. Imagine that the user's cursor is hovering over |
| | 18 | * "Gorillas." When they move their mouse into the "Gorilla Content" area, they |
| | 19 | * may briefly hover over "Chimps." This shouldn't close the "Gorilla Content" |
| | 20 | * area. |
| | 21 | * |
| | 22 | * This problem is normally solved using timeouts and delays. menu-aim tries to |
| | 23 | * solve this by detecting the direction of the user's mouse movement. This can |
| | 24 | * make for quicker transitions when navigating up and down the menu. The |
| | 25 | * experience is hopefully similar to amazon.com/'s "Shop by Department" |
| | 26 | * dropdown. |
| | 27 | * |
| | 28 | * Use like so: |
| | 29 | * |
| | 30 | * $("#menu").menuAim({ |
| | 31 | * activate: $.noop, // fired on row activation |
| | 32 | * deactivate: $.noop, // fired on row deactivation |
| | 33 | * }); |
| | 34 | * |
| | 35 | * ...to receive events when a menu's row has been purposefully (de)activated. |
| | 36 | * |
| | 37 | * The following options can be passed to menuAim. All functions execute with |
| | 38 | * the relevant row's HTML element as the execution context ('this'): |
| | 39 | * |
| | 40 | * .menuAim({ |
| | 41 | * // Function to call when a row is purposefully activated. Use this |
| | 42 | * // to show a submenu's content for the activated row. |
| | 43 | * activate: function() {}, |
| | 44 | * |
| | 45 | * // Function to call when a row is deactivated. |
| | 46 | * deactivate: function() {}, |
| | 47 | * |
| | 48 | * // Function to call when mouse enters a menu row. Entering a row |
| | 49 | * // does not mean the row has been activated, as the user may be |
| | 50 | * // mousing over to a submenu. |
| | 51 | * enter: function() {}, |
| | 52 | * |
| | 53 | * // Function to call when mouse exits a menu row. |
| | 54 | * exit: function() {}, |
| | 55 | * |
| | 56 | * // Selector for identifying which elements in the menu are rows |
| | 57 | * // that can trigger the above events. Defaults to "> li". |
| | 58 | * rowSelector: "> li", |
| | 59 | * |
| | 60 | * // You may have some menu rows that aren't submenus and therefore |
| | 61 | * // shouldn't ever need to "activate." If so, filter submenu rows w/ |
| | 62 | * // this selector. Defaults to "*" (all elements). |
| | 63 | * submenuSelector: "*" |
| | 64 | * }); |
| | 65 | * |
| | 66 | * https://github.com/kamens/jQuery-menu-aim |
| | 67 | */ |
| | 68 | (function($) { |
| | 69 | $.fn.menuAim = function(opts) { |
| | 70 | |
| | 71 | var $menu = $(this), |
| | 72 | activeRow = null, |
| | 73 | mouseLocs = [], |
| | 74 | lastDelayLoc = null, |
| | 75 | timeoutId = null, |
| | 76 | options = $.extend({ |
| | 77 | rowSelector: "> li", |
| | 78 | submenuSelector: "*", |
| | 79 | tolerance: 75, // bigger = more forgivey when entering submenu |
| | 80 | enter: $.noop, |
| | 81 | exit: $.noop, |
| | 82 | activate: $.noop, |
| | 83 | deactivate: $.noop |
| | 84 | }, opts); |
| | 85 | |
| | 86 | var MOUSE_LOCS_TRACKED = 3, // number of past mouse locations to track |
| | 87 | DELAY = 300; // ms delay when user appears to be entering submenu |
| | 88 | |
| | 89 | /** |
| | 90 | * Keep track of the last few locations of the mouse. |
| | 91 | */ |
| | 92 | var mousemoveDocument = function(e) { |
| | 93 | mouseLocs.push({x: e.pageX, y: e.pageY}); |
| | 94 | |
| | 95 | if (mouseLocs.length > MOUSE_LOCS_TRACKED) { |
| | 96 | mouseLocs.shift(); |
| | 97 | } |
| | 98 | }; |
| | 99 | |
| | 100 | /** |
| | 101 | * Cancel possible row activations when leaving the menu entirely |
| | 102 | */ |
| | 103 | var mouseleaveMenu = function() { |
| | 104 | if (timeoutId) { |
| | 105 | clearTimeout(timeoutId); |
| | 106 | } |
| | 107 | }; |
| | 108 | |
| | 109 | /** |
| | 110 | * Trigger a possible row activation whenever entering a new row. |
| | 111 | */ |
| | 112 | var mouseenterRow = function() { |
| | 113 | if (timeoutId) { |
| | 114 | // Cancel any previous activation delays |
| | 115 | clearTimeout(timeoutId); |
| | 116 | } |
| | 117 | |
| | 118 | options.enter(this); |
| | 119 | possiblyActivate(this); |
| | 120 | }, |
| | 121 | mouseleaveRow = function() { |
| | 122 | options.exit(this); |
| | 123 | }; |
| | 124 | |
| | 125 | /** |
| | 126 | * Activate a menu row. |
| | 127 | */ |
| | 128 | var activate = function(row) { |
| | 129 | if (row == activeRow) { |
| | 130 | return; |
| | 131 | } |
| | 132 | |
| | 133 | if (activeRow) { |
| | 134 | options.deactivate(activeRow); |
| | 135 | } |
| | 136 | |
| | 137 | options.activate(row); |
| | 138 | activeRow = row; |
| | 139 | }; |
| | 140 | |
| | 141 | /** |
| | 142 | * Possibly activate a menu row. If mouse movement indicates that we |
| | 143 | * shouldn't activate yet because user may be trying to enter |
| | 144 | * a submenu's content, then delay and check again later. |
| | 145 | */ |
| | 146 | var possiblyActivate = function(row) { |
| | 147 | var delay = activationDelay(); |
| | 148 | |
| | 149 | if (delay) { |
| | 150 | timeoutId = setTimeout(function() { |
| | 151 | possiblyActivate(row); |
| | 152 | }, delay); |
| | 153 | } else { |
| | 154 | activate(row); |
| | 155 | } |
| | 156 | }; |
| | 157 | |
| | 158 | /** |
| | 159 | * Return the amount of time that should be used as a delay before the |
| | 160 | * currently hovered row is activated. |
| | 161 | * |
| | 162 | * Returns 0 if the activation should happen immediately. Otherwise, |
| | 163 | * returns the number of milliseconds that should be delayed before |
| | 164 | * checking again to see if the row should be activated. |
| | 165 | */ |
| | 166 | var activationDelay = function() { |
| | 167 | if (!activeRow || !$(activeRow).is(options.submenuSelector)) { |
| | 168 | // If there is no other submenu row already active, then |
| | 169 | // go ahead and activate immediately. |
| | 170 | return 0; |
| | 171 | } |
| | 172 | |
| | 173 | var offset = $menu.offset(), |
| | 174 | upperRight = { |
| | 175 | x: offset.left + $menu.outerWidth(), |
| | 176 | y: offset.top - options.tolerance |
| | 177 | }, |
| | 178 | lowerRight = { |
| | 179 | x: offset.left + $menu.outerWidth(), |
| | 180 | y: offset.top + $menu.outerHeight() + options.tolerance |
| | 181 | }, |
| | 182 | loc = mouseLocs[mouseLocs.length - 1], |
| | 183 | prevLoc = mouseLocs[0]; |
| | 184 | |
| | 185 | if (!loc) { |
| | 186 | return 0; |
| | 187 | } |
| | 188 | |
| | 189 | if (!prevLoc) { |
| | 190 | prevLoc = loc; |
| | 191 | } |
| | 192 | |
| | 193 | if (prevLoc.x < offset.left || prevLoc.x > lowerRight.x || |
| | 194 | prevLoc.y < offset.top || prevLoc.y > lowerRight.y) { |
| | 195 | // If the previous mouse location was outside of the entire |
| | 196 | // menu's bounds, immediately activate. |
| | 197 | return 0; |
| | 198 | } |
| | 199 | |
| | 200 | if (lastDelayLoc && |
| | 201 | loc.x == lastDelayLoc.x && loc.y == lastDelayLoc.y) { |
| | 202 | // If the mouse hasn't moved since the last time we checked |
| | 203 | // for activation status, immediately activate. |
| | 204 | return 0; |
| | 205 | } |
| | 206 | |
| | 207 | // Detect if the user is moving towards the currently activated |
| | 208 | // submenu. |
| | 209 | // |
| | 210 | // If the mouse is heading relatively clearly towards |
| | 211 | // the submenu's content, we should wait and give the user more |
| | 212 | // time before activating a new row. If the mouse is heading |
| | 213 | // elsewhere, we can immediately activate a new row. |
| | 214 | // |
| | 215 | // We detect this by calculating the slope formed between the |
| | 216 | // current mouse location and the upper/lower right points of |
| | 217 | // the menu. We do the same for the previous mouse location. |
| | 218 | // If the current mouse location's slopes are |
| | 219 | // increasing/decreasing appropriately compared to the |
| | 220 | // previous's, we know the user is moving toward the submenu. |
| | 221 | // |
| | 222 | // Note that since the y-axis increases as the cursor moves |
| | 223 | // down the screen, we are looking for the slope between the |
| | 224 | // cursor and the upper right corner to decrease over time, not |
| | 225 | // increase (somewhat counterintuitively). |
| | 226 | function slope(a, b) { |
| | 227 | return (b.y - a.y) / (b.x - a.x); |
| | 228 | }; |
| | 229 | |
| | 230 | var upperSlope = slope(loc, upperRight), |
| | 231 | lowerSlope = slope(loc, lowerRight), |
| | 232 | prevUpperSlope = slope(prevLoc, upperRight), |
| | 233 | prevLowerSlope = slope(prevLoc, lowerRight); |
| | 234 | |
| | 235 | if (upperSlope < prevUpperSlope && |
| | 236 | lowerSlope > prevLowerSlope) { |
| | 237 | // Mouse is moving from previous location towards the |
| | 238 | // currently activated submenu. Delay before activating a |
| | 239 | // new menu row, because user may be moving into submenu. |
| | 240 | lastDelayLoc = loc; |
| | 241 | return DELAY; |
| | 242 | } |
| | 243 | |
| | 244 | lastDelayLoc = null; |
| | 245 | return 0; |
| | 246 | }; |
| | 247 | |
| | 248 | /** |
| | 249 | * Hook up initial menu events |
| | 250 | */ |
| | 251 | var init = function() { |
| | 252 | $menu |
| | 253 | .mouseleave(mouseleaveMenu) |
| | 254 | .find(options.rowSelector) |
| | 255 | .mouseenter(mouseenterRow) |
| | 256 | .mouseleave(mouseleaveRow); |
| | 257 | |
| | 258 | $(document).mousemove(mousemoveDocument); |
| | 259 | }; |
| | 260 | |
| | 261 | init(); |
| | 262 | return this; |
| | 263 | }; |
| | 264 | })(jQuery); |