/**
* menu-aim is a jQuery plugin for dropdown menus that can differentiate
* between a user trying hover over a dropdown item vs trying to navigate into
* a submenu's contents.
*
* menu-aim assumes that you have are using a menu with submenus that expand
* to the menu's right. It will fire events when the user's mouse enters a new
* dropdown item *and* when that item is being intentionally hovered over.
*
* __________________________
* | Monkeys >| Gorilla |
* | Gorillas >| Content |
* | Chimps >| Here |
* |___________|____________|
*
* In the above example, "Gorillas" is selected and its submenu content is
* being shown on the right. Imagine that the user's cursor is hovering over
* "Gorillas." When they move their mouse into the "Gorilla Content" area, they
* may briefly hover over "Chimps." This shouldn't close the "Gorilla Content"
* area.
*
* This problem is normally solved using timeouts and delays. menu-aim tries to
* solve this by detecting the direction of the user's mouse movement. This can
* make for quicker transitions when navigating up and down the menu. The
* experience is hopefully similar to amazon.com/'s "Shop by Department"
* dropdown.
*
* Use like so:
*
* $("#menu").menuAim({
* activate: $.noop, // fired on row activation
* deactivate: $.noop, // fired on row deactivation
* });
*
* ...to receive events when a menu's row has been purposefully (de)activated.
*
* The following options can be passed to menuAim. All functions execute with
* the relevant row's HTML element as the execution context ('this'):
*
* .menuAim({
* // Function to call when a row is purposefully activated. Use this
* // to show a submenu's content for the activated row.
* activate: function() {},
*
* // Function to call when a row is deactivated.
* deactivate: function() {},
*
* // Function to call when mouse enters a menu row. Entering a row
* // does not mean the row has been activated, as the user may be
* // mousing over to a submenu.
* enter: function() {},
*
* // Function to call when mouse exits a menu row.
* exit: function() {},
*
* // Selector for identifying which elements in the menu are rows
* // that can trigger the above events. Defaults to "> li".
* rowSelector: "> li",
*
* // You may have some menu rows that aren't submenus and therefore
* // shouldn't ever need to "activate." If so, filter submenu rows w/
* // this selector. Defaults to "*" (all elements).
* submenuSelector: "*"
* });
*
* https://github.com/kamens/jQuery-menu-aim
*/
(
function
(
$
)
{
$
.
fn
.
menuAim
=
function
(
opts
)
{
var
$menu
=
$
(
this
),
activeRow
=
null
,
mouseLocs
=
[],
lastDelayLoc
=
null
,
timeoutId
=
null
,
options
=
$
.
extend
({
rowSelector
:
"> li"
,
submenuSelector
:
"*"
,
tolerance
:
75
,
// bigger = more forgivey when entering submenu
enter
:
$
.
noop
,
exit
:
$
.
noop
,
activate
:
$
.
noop
,
deactivate
:
$
.
noop
},
opts
);
var
MOUSE_LOCS_TRACKED
=
3
,
// number of past mouse locations to track
DELAY
=
300
;
// ms delay when user appears to be entering submenu
/**
* Keep track of the last few locations of the mouse.
*/
var
mousemoveDocument
=
function
(
e
)
{
mouseLocs
.
push
({
x
:
e
.
pageX
,
y
:
e
.
pageY
});
if
(
mouseLocs
.
length
>
MOUSE_LOCS_TRACKED
)
{
mouseLocs
.
shift
();
}
};
/**
* Cancel possible row activations when leaving the menu entirely
*/
var
mouseleaveMenu
=
function
()
{
if
(
timeoutId
)
{
clearTimeout
(
timeoutId
);
}
};
/**
* Trigger a possible row activation whenever entering a new row.
*/
var
mouseenterRow
=
function
()
{
if
(
timeoutId
)
{
// Cancel any previous activation delays
clearTimeout
(
timeoutId
);
}
options
.
enter
(
this
);
possiblyActivate
(
this
);
},
mouseleaveRow
=
function
()
{
options
.
exit
(
this
);
};
/**
* Activate a menu row.
*/
var
activate
=
function
(
row
)
{
if
(
row
==
activeRow
)
{
return
;
}
if
(
activeRow
)
{
options
.
deactivate
(
activeRow
);
}
options
.
activate
(
row
);
activeRow
=
row
;
};
/**
* Possibly activate a menu row. If mouse movement indicates that we
* shouldn't activate yet because user may be trying to enter
* a submenu's content, then delay and check again later.
*/
var
possiblyActivate
=
function
(
row
)
{
var
delay
=
activationDelay
();
if
(
delay
)
{
timeoutId
=
setTimeout
(
function
()
{
possiblyActivate
(
row
);
},
delay
);
}
else
{
activate
(
row
);
}
};
/**
* Return the amount of time that should be used as a delay before the
* currently hovered row is activated.
*
* Returns 0 if the activation should happen immediately. Otherwise,
* returns the number of milliseconds that should be delayed before
* checking again to see if the row should be activated.
*/
var
activationDelay
=
function
()
{
if
(
!
activeRow
||
!
$
(
activeRow
).
is
(
options
.
submenuSelector
))
{
// If there is no other submenu row already active, then
// go ahead and activate immediately.
return
0
;
}
var
offset
=
$menu
.
offset
(),
upperRight
=
{
x
:
offset
.
left
+
$menu
.
outerWidth
(),
y
:
offset
.
top
-
options
.
tolerance
},
lowerRight
=
{
x
:
offset
.
left
+
$menu
.
outerWidth
(),
y
:
offset
.
top
+
$menu
.
outerHeight
()
+
options
.
tolerance
},
loc
=
mouseLocs
[
mouseLocs
.
length
-
1
],
prevLoc
=
mouseLocs
[
0
];
if
(
!
loc
)
{
return
0
;
}
if
(
!
prevLoc
)
{
prevLoc
=
loc
;
}
if
(
prevLoc
.
x
<
offset
.
left
||
prevLoc
.
x
>
lowerRight
.
x
||
prevLoc
.
y
<
offset
.
top
||
prevLoc
.
y
>
lowerRight
.
y
)
{
// If the previous mouse location was outside of the entire
// menu's bounds, immediately activate.
return
0
;
}
if
(
lastDelayLoc
&&
loc
.
x
==
lastDelayLoc
.
x
&&
loc
.
y
==
lastDelayLoc
.
y
)
{
// If the mouse hasn't moved since the last time we checked
// for activation status, immediately activate.
return
0
;
}
// Detect if the user is moving towards the currently activated
// submenu.
//
// If the mouse is heading relatively clearly towards
// the submenu's content, we should wait and give the user more
// time before activating a new row. If the mouse is heading
// elsewhere, we can immediately activate a new row.
//
// We detect this by calculating the slope formed between the
// current mouse location and the upper/lower right points of
// the menu. We do the same for the previous mouse location.
// If the current mouse location's slopes are
// increasing/decreasing appropriately compared to the
// previous's, we know the user is moving toward the submenu.
//
// Note that since the y-axis increases as the cursor moves
// down the screen, we are looking for the slope between the
// cursor and the upper left corner to decrease over time, not
// increase (somewhat counterintuitively).
function
slope
(
a
,
b
)
{
return
(
b
.
y
-
a
.
y
)
/
(
b
.
x
-
a
.
x
);
};
var
upperSlope
=
slope
(
loc
,
upperRight
),
lowerSlope
=
slope
(
loc
,
lowerRight
),
prevUpperSlope
=
slope
(
prevLoc
,
upperRight
),
prevLowerSlope
=
slope
(
prevLoc
,
lowerRight
);
if
(
upperSlope
<
prevUpperSlope
&&
lowerSlope
>
prevLowerSlope
)
{
// Mouse is moving from previous location towards the
// currently activated submenu. Delay before activating a
// new menu row, because user may be moving into submenu.
lastDelayLoc
=
loc
;
return
DELAY
;
}
lastDelayLoc
=
null
;
return
0
;
};
/**
* Hook up initial menu events
*/
var
init
=
function
()
{
$menu
.
mouseleave
(
mouseleaveMenu
)
.
find
(
options
.
rowSelector
)
.
mouseenter
(
mouseenterRow
)
.
mouseleave
(
mouseleaveRow
);
$
(
document
).
mousemove
(
mousemoveDocument
);
};
init
();
return
this
;
};
})(
jQuery
);