jquery.menu-aim.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. * // Direction the submenu opens relative to the main menu. Can be
  66. * // left, right, above, or below. Defaults to "right".
  67. * submenuDirection: "right"
  68. * });
  69. *
  70. * https://github.com/kamens/jQuery-menu-aim
  71. * MIT License
  72. */
  73. (function($) {
  74. $.fn.menuAim = function(opts) {
  75. // Initialize menu-aim for all elements in jQuery collection
  76. this.each(function() {
  77. init.call(this, opts);
  78. });
  79. return this;
  80. };
  81. function init(opts) {
  82. var $menu = $(this),
  83. activeRow = null,
  84. mouseLocs = [],
  85. lastDelayLoc = null,
  86. timeoutId = null,
  87. options = $.extend({
  88. rowSelector: "> li",
  89. submenuSelector: "*",
  90. submenuDirection: "right",
  91. tolerance: 75, // bigger = more forgivey when entering submenu
  92. enter: $.noop,
  93. exit: $.noop,
  94. activate: $.noop,
  95. deactivate: $.noop,
  96. exitMenu: $.noop
  97. }, opts);
  98. var MOUSE_LOCS_TRACKED = 3, // number of past mouse locations to track
  99. DELAY = 300; // ms delay when user appears to be entering submenu
  100. /**
  101. * Keep track of the last few locations of the mouse.
  102. */
  103. var mousemoveDocument = function(e) {
  104. mouseLocs.push({x: e.pageX, y: e.pageY});
  105. if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
  106. mouseLocs.shift();
  107. }
  108. };
  109. /**
  110. * Cancel possible row activations when leaving the menu entirely
  111. */
  112. var mouseleaveMenu = function() {
  113. if (timeoutId) {
  114. clearTimeout(timeoutId);
  115. }
  116. // If exitMenu is supplied and returns true, deactivate the
  117. // currently active row on menu exit.
  118. if (options.exitMenu(this)) {
  119. if (activeRow) {
  120. options.deactivate(activeRow);
  121. }
  122. activeRow = null;
  123. }
  124. };
  125. /**
  126. * Trigger a possible row activation whenever entering a new row.
  127. */
  128. var mouseenterRow = function() {
  129. if (timeoutId) {
  130. // Cancel any previous activation delays
  131. clearTimeout(timeoutId);
  132. }
  133. options.enter(this);
  134. possiblyActivate(this);
  135. },
  136. mouseleaveRow = function() {
  137. options.exit(this);
  138. };
  139. /*
  140. * Immediately activate a row if the user clicks on it.
  141. */
  142. var clickRow = function() {
  143. activate(this);
  144. };
  145. /**
  146. * Activate a menu row.
  147. */
  148. var activate = function(row) {
  149. if (row == activeRow) {
  150. return;
  151. }
  152. if (activeRow) {
  153. options.deactivate(activeRow);
  154. }
  155. options.activate(row);
  156. activeRow = row;
  157. };
  158. /**
  159. * Possibly activate a menu row. If mouse movement indicates that we
  160. * shouldn't activate yet because user may be trying to enter
  161. * a submenu's content, then delay and check again later.
  162. */
  163. var possiblyActivate = function(row) {
  164. var delay = activationDelay();
  165. if (delay) {
  166. timeoutId = setTimeout(function() {
  167. possiblyActivate(row);
  168. }, delay);
  169. } else {
  170. activate(row);
  171. }
  172. };
  173. /**
  174. * Return the amount of time that should be used as a delay before the
  175. * currently hovered row is activated.
  176. *
  177. * Returns 0 if the activation should happen immediately. Otherwise,
  178. * returns the number of milliseconds that should be delayed before
  179. * checking again to see if the row should be activated.
  180. */
  181. var activationDelay = function() {
  182. if (!activeRow || !$(activeRow).is(options.submenuSelector)) {
  183. // If there is no other submenu row already active, then
  184. // go ahead and activate immediately.
  185. return 0;
  186. }
  187. var offset = $menu.offset(),
  188. upperLeft = {
  189. x: offset.left,
  190. y: offset.top - options.tolerance
  191. },
  192. upperRight = {
  193. x: offset.left + $menu.outerWidth(),
  194. y: upperLeft.y
  195. },
  196. lowerLeft = {
  197. x: offset.left,
  198. y: offset.top + $menu.outerHeight() + options.tolerance
  199. },
  200. lowerRight = {
  201. x: offset.left + $menu.outerWidth(),
  202. y: lowerLeft.y
  203. },
  204. loc = mouseLocs[mouseLocs.length - 1],
  205. prevLoc = mouseLocs[0];
  206. if (!loc) {
  207. return 0;
  208. }
  209. if (!prevLoc) {
  210. prevLoc = loc;
  211. }
  212. if (prevLoc.x < offset.left || prevLoc.x > lowerRight.x ||
  213. prevLoc.y < offset.top || prevLoc.y > lowerRight.y) {
  214. // If the previous mouse location was outside of the entire
  215. // menu's bounds, immediately activate.
  216. return 0;
  217. }
  218. if (lastDelayLoc &&
  219. loc.x == lastDelayLoc.x && loc.y == lastDelayLoc.y) {
  220. // If the mouse hasn't moved since the last time we checked
  221. // for activation status, immediately activate.
  222. return 0;
  223. }
  224. // Detect if the user is moving towards the currently activated
  225. // submenu.
  226. //
  227. // If the mouse is heading relatively clearly towards
  228. // the submenu's content, we should wait and give the user more
  229. // time before activating a new row. If the mouse is heading
  230. // elsewhere, we can immediately activate a new row.
  231. //
  232. // We detect this by calculating the slope formed between the
  233. // current mouse location and the upper/lower right points of
  234. // the menu. We do the same for the previous mouse location.
  235. // If the current mouse location's slopes are
  236. // increasing/decreasing appropriately compared to the
  237. // previous's, we know the user is moving toward the submenu.
  238. //
  239. // Note that since the y-axis increases as the cursor moves
  240. // down the screen, we are looking for the slope between the
  241. // cursor and the upper right corner to decrease over time, not
  242. // increase (somewhat counterintuitively).
  243. function slope(a, b) {
  244. return (b.y - a.y) / (b.x - a.x);
  245. };
  246. var decreasingCorner = upperRight,
  247. increasingCorner = lowerRight;
  248. // Our expectations for decreasing or increasing slope values
  249. // depends on which direction the submenu opens relative to the
  250. // main menu. By default, if the menu opens on the right, we
  251. // expect the slope between the cursor and the upper right
  252. // corner to decrease over time, as explained above. If the
  253. // submenu opens in a different direction, we change our slope
  254. // expectations.
  255. if (options.submenuDirection == "left") {
  256. decreasingCorner = lowerLeft;
  257. increasingCorner = upperLeft;
  258. } else if (options.submenuDirection == "below") {
  259. decreasingCorner = lowerRight;
  260. increasingCorner = lowerLeft;
  261. } else if (options.submenuDirection == "above") {
  262. decreasingCorner = upperLeft;
  263. increasingCorner = upperRight;
  264. }
  265. var decreasingSlope = slope(loc, decreasingCorner),
  266. increasingSlope = slope(loc, increasingCorner),
  267. prevDecreasingSlope = slope(prevLoc, decreasingCorner),
  268. prevIncreasingSlope = slope(prevLoc, increasingCorner);
  269. if (decreasingSlope < prevDecreasingSlope &&
  270. increasingSlope > prevIncreasingSlope) {
  271. // Mouse is moving from previous location towards the
  272. // currently activated submenu. Delay before activating a
  273. // new menu row, because user may be moving into submenu.
  274. lastDelayLoc = loc;
  275. return DELAY;
  276. }
  277. lastDelayLoc = null;
  278. return 0;
  279. };
  280. /**
  281. * Hook up initial menu events
  282. */
  283. $menu
  284. .mouseleave(mouseleaveMenu)
  285. .find(options.rowSelector)
  286. .mouseenter(mouseenterRow)
  287. .mouseleave(mouseleaveRow)
  288. .click(clickRow);
  289. $(document).mousemove(mousemoveDocument);
  290. };
  291. })(jQuery);