react-menu-aim
Version:
A React mixin version of Amazon's jQuery-menu-aim plugin
301 lines (250 loc) • 8.07 kB
JavaScript
/**
* react-menu-aim is a React Mixin heavily inspired by jQuery-menu-aim. All rights
* reserved by the original author.
*
* https://github.com/jasonslyvia/react-menu-aim
* https://github.com/kamens/jQuery-menu-aim
*/
var ReactDOM = require('react-dom');
var MOUSE_LOCS_TRACKED = 3; // number of past mouse locations to trackv
var DELAY = 300; // ms delay when user appears to be entering submenu
var TOLERANCE = 75; // bigger = more forgivey when entering submenu
/**
*
* DOM helpers
*
*/
function on(el, eventName, callback) {
if (el.addEventListener) {
el.addEventListener(eventName, callback, false);
}
else if (el.attachEvent) {
el.attachEvent('on'+eventName, function(e) {
callback.call(el, e || window.event);
});
}
}
function off(el, eventName, callback) {
if (el.removeEventListener) {
el.removeEventListener(eventName, callback);
}
else if (el.detachEvent) {
el.detachEvent('on'+eventName, callback);
}
}
function offset(el) {
if (!el) {
return {
left: 0,
top: 0
};
}
var rect = el.getBoundingClientRect();
return {
top: rect.top + document.body.scrollTop,
left: rect.left + document.body.scrollLeft
};
}
function outerWidth(el) {
var _width = el.offsetWidth;
var style = el.currentStyle || getComputedStyle(el);
_width += (parseInt(style.marginLeft, 10) || 0);
return _width;
}
function outerHeight(el) {
var _height = el.offsetHeight;
var style = el.currentStyle || getComputedStyle(el);
_height += (parseInt(style.marginLeft, 10) || 0);
return _height;
}
/**
*
* Util helpers
*
*/
// Consider multiple instance using ReactMenuAim, we just listen mousemove once
var mousemoveListener = 0;
var mouseLocs = [];
// Mousemove handler on document
function handleMouseMoveDocument(e) {
mouseLocs.push({
x: e.pageX,
y: e.pageY
});
if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
mouseLocs.shift();
}
}
function getActivateDelay(config) {
config = config || {};
var menu = ReactDOM.findDOMNode(this);
// If can't find any DOM node
if (!menu || !menu.querySelector) {
return 0;
}
menu = config.menuSelector ? menu.querySelector(config.menuSelector) : menu;
var menuOffset = offset(menu);
var upperLeft = {
x: menuOffset.left,
y: menuOffset.top - (config.tolerance || TOLERANCE)
};
var upperRight = {
x: menuOffset.left + outerWidth(menu),
y: upperLeft.y
};
var lowerLeft = {
x: menuOffset.left,
y: menuOffset.top + outerHeight(menu) + (config.tolerance || TOLERANCE)
};
var lowerRight = {
x: menuOffset.left + outerWidth(menu),
y: lowerLeft.y
};
var loc = mouseLocs[mouseLocs.length - 1];
var prevLoc = mouseLocs[0];
if (!loc) {
return 0;
}
if (!prevLoc) {
prevLoc = loc;
}
// If the previous mouse location was outside of the entire
// menu's bounds, immediately activate.
if (prevLoc.x < menuOffset.left || prevLoc.x > lowerRight.x ||
prevLoc.y < menuOffset.top || prevLoc.y > lowerRight.y
) {
return 0;
}
// If the mouse hasn't moved since the last time we checked
// for activation status, immediately activate.
if (this._lastDelayDoc &&
loc.x === this._lastDelayDoc.x && loc.y === this._lastDelayDoc.y) {
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 right corner to decrease over time, not
// increase (somewhat counterintuitively).
function slope(a, b) {
return (b.y - a.y) / (b.x - a.x);
}
var decreasingCorner = upperRight;
var increasingCorner = lowerRight;
// Our expectations for decreasing or increasing slope values
// depends on which direction the submenu opens relative to the
// main menu. By default, if the menu opens on the right, we
// expect the slope between the cursor and the upper right
// corner to decrease over time, as explained above. If the
// submenu opens in a different direction, we change our slope
// expectations.
if (config.submenuDirection === 'left') {
decreasingCorner = lowerLeft;
increasingCorner = upperLeft;
}
else if (config.submenuDirection === 'below') {
decreasingCorner = lowerRight;
increasingCorner = lowerLeft;
}
else if (config.submenuDirection === 'above') {
decreasingCorner = upperLeft;
}
var decreasingSlope = slope(loc, decreasingCorner);
var increasingSlope = slope(loc, increasingCorner);
var prevDecreasingSlope = slope(prevLoc, decreasingCorner);
var prevIncreasingSlope = slope(prevLoc, increasingCorner);
// 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.
if (decreasingSlope < prevDecreasingSlope && increasingSlope > prevIncreasingSlope) {
this._lastDelayLoc = loc;
return config.delay || DELAY;
}
this._lastDelayLoc = null;
return 0;
}
// For the concern of further changes might happen when activeRowIndex changes,
// this mixin doesn't setState directly, instead it calls the callback provided
// by user.
function activate(rowIdentifier, handler) {
handler.call(this, rowIdentifier);
}
function possiblyActivate(rowIdentifier, handler, config) {
var delay = getActivateDelay.call(this, config);
if (delay) {
var self = this;
this.__reactMenuAimTimer = setTimeout(function(){
possiblyActivate.call(self, rowIdentifier, handler, config);
}, delay);
}
else {
activate.call(this, rowIdentifier, handler);
}
}
/**
* @export
*/
module.exports = exports = {
initMenuAim: function(options) {
this.__reactMenuAimConfig = options;
},
__getMouseMoveDocumentHandler: function() {
if (!this.__mouseMoveDocumentHandler) {
this.__mouseMoveDocumentHandler = handleMouseMoveDocument.bind(this);
}
return this.__mouseMoveDocumentHandler;
},
componentDidMount: function() {
if (mousemoveListener === 0) {
on(document, 'mousemove', this.__getMouseMoveDocumentHandler());
}
mousemoveListener += 1;
},
componentWillUnmount: function() {
mousemoveListener -= 1;
if (mousemoveListener === 0) {
off(document, 'mousemove', this.__getMouseMoveDocumentHandler());
mouseLocs = [];
}
clearTimeout(this.__reactMenuAimTimer);
this.__reactMenuAimTimer = null;
this.__mouseMoveDocumentHandler = null;
},
/**
* @param {function} handler The true event handler for your app
* @param {object} e React's synthetic event object
*/
handleMouseLeaveMenu: function(handler, e) {
if (this.__reactMenuAimTimer) {
clearTimeout(this.__reactMenuAimTimer);
}
if (typeof handler === 'function') {
handler.call(this, e);
}
},
/**
* @param {number} rowIdentifier The identifier of current row, ie. index or name
* @param {function} handler The true event handler for your app
* @param {object} e React's synthetic event object
*/
handleMouseEnterRow: function(rowIdentifier, handler) {
if (this.__reactMenuAimTimer) {
clearTimeout(this.__reactMenuAimTimer);
}
possiblyActivate.call(this, rowIdentifier, handler, this.__reactMenuAimConfig);
}
};