react-data-menu
Version:
Smart data-driven menu rendered in an overlay
583 lines (480 loc) • 19.9 kB
JavaScript
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.POPUP_ID_PREFIX = undefined;
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
var _lodash = require('lodash');
var _lodash2 = _interopRequireDefault(_lodash);
var _react = require('react');
var _react2 = _interopRequireDefault(_react);
var _Aligner = require('./../util/Aligner.js');
var _Aligner2 = _interopRequireDefault(_Aligner);
var _defaultRenderers = require('./../renderers/defaultRenderers.js');
var _Dom = require('./../util/Dom');
var _Dom2 = _interopRequireDefault(_Dom);
var _hoverData = require('./../util/hoverData');
var _hoverData2 = _interopRequireDefault(_hoverData);
var _hoverDataBuilder = require('./../util/hoverDataBuilder');
var _hoverDataBuilder2 = _interopRequireDefault(_hoverDataBuilder);
var _MenuEmitter = require('./../emitters/MenuEmitter.js');
var _MenuEmitter2 = _interopRequireDefault(_MenuEmitter);
var _MenuItemFactory = require('./MenuItemFactory.js');
var _MenuItemFactory2 = _interopRequireDefault(_MenuItemFactory);
var _MenuPopup = require('./MenuPopup');
var _MenuPopup2 = _interopRequireDefault(_MenuPopup);
var _MenuPopupFactory = require('./MenuPopupFactory.js');
var _MenuPopupFactory2 = _interopRequireDefault(_MenuPopupFactory);
var _reactLiberator = require('react-liberator');
var _reactLiberator2 = _interopRequireDefault(_reactLiberator);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
var POPUP_ID_PREFIX = exports.POPUP_ID_PREFIX = 'menu-popup-';
var DEFAULT_LAYER_ID = 'react-data-menu-popup',
MOUSE_LEAVE_DELAY = 100,
MOUSE_ENTER_DELAY = 200,
HINTS = function HINTS() {
return ['es', 'em', 'ee', 'ws', 'wm', 'we'];
},
ALIGN_TO = function ALIGN_TO(level) {
return !level ? this.props.alignTo || this.props.position : this.currentHoverData ? this.currentHoverData.getElement() : null;
};
// references to all the open menu instances
// usually only the single instance, since by default we're allowing only a single menu on screen (we're auto-closing others)
var instances = [],
layerElement;
var Menu = function (_Component) {
_inherits(Menu, _Component);
//<editor-fold desc="Constructor">
function Menu(props) {
_classCallCheck(this, Menu);
var _this = _possibleConstructorReturn(this, (Menu.__proto__ || Object.getPrototypeOf(Menu)).call(this, props));
_this.onItemClick = _this.onItemClick.bind(_this);
_this.closeMenu = _this.closeMenu.bind(_this);
_this.createPopup = _this.createPopup.bind(_this);
_this.removeChildPopups = _this.removeChildPopups.bind(_this);
_this.removePopups = _this.removePopups.bind(_this);
// debouncing: we don't want the child popup to open/close immediately
_this.processInActionDebounced = _lodash2.default.debounce(_this.processInAction.bind(_this), MOUSE_ENTER_DELAY);
_this.processOutActionDebounced = _lodash2.default.debounce(_this.processOutAction.bind(_this), MOUSE_LEAVE_DELAY);
_this.popupFactory = new _MenuPopupFactory2.default(_this.props.classPrefix);
_this.itemFactory = new _MenuItemFactory2.default(_lodash2.default.assign(_defaultRenderers.DefaultRenderers, props.renderers), props.classPrefix);
_this.state = {
visible: false,
expanded: false,
popups: []
};
_this.currentHoverData = null;
_this.handlers = {
onMouseDownOutside: _this.onMouseDownOutside.bind(_this),
onMouseUpInside: _this.onMouseUpInside.bind(_this),
onContextMenuInside: _this.onContextMenuInside.bind(_this),
onClose: _this.closeMenu,
onMouseOver: _this.onMouseOver.bind(_this),
onMouseOut: _this.onMouseOut.bind(_this),
onTouchStart: _this.onTouchStart.bind(_this),
onTouchEnd: _this.onTouchEnd.bind(_this),
onTouchStartOutside: _this.onTouchStartOutside.bind(_this)
};
return _this;
}
//</editor-fold>
//<editor-fold desc="Instance handling">
/**
* Only a single menu instance should be visible on screen
* Instances do close on window click, however they might get instantiated by other means
* (mouse-over the drop-down button etc.)
*/
_createClass(Menu, [{
key: 'closeOtherMenuInstances',
value: function closeOtherMenuInstances() {
var self = this;
_lodash2.default.forEach(instances, function (instance) {
if (instance !== self) {
instance.closeMenu();
}
});
}
/**
* Removes this menu from instances array
*/
}, {
key: 'removeInstance',
value: function removeInstance() {
var self = this;
_lodash2.default.remove(instances, function (instance) {
return self === instance;
});
}
//</editor-fold>
//<editor-fold desc="Emitter subscribe / unsubscribe">
/**
* Event subscription
* Happens when menu becomes visible
*/
}, {
key: 'connectToDispatcher',
value: function connectToDispatcher() {
_MenuEmitter2.default.getInstance().connect(this.handlers);
}
/**
* Unsubscribing from events
* Happens when menu becomes hidden
*/
}, {
key: 'disconnectFromDispatcher',
value: function disconnectFromDispatcher() {
_MenuEmitter2.default.getInstance().disconnect(this.handlers);
}
//</editor-fold>
//<editor-fold desc="Raycast">
/**
* Fires on context menu inside of the menu
* @param ray
*/
}, {
key: 'onContextMenuInside',
value: function onContextMenuInside(ray) {
ray.preventDefault(); // prevent default menu
}
}, {
key: 'onMouseOver',
value: function onMouseOver(ray) {
var hoverData = _hoverDataBuilder2.default.build(this.state.popups, ray),
popups;
if (!hoverData) {
return;
}
// keeping the selection
// Note: the selection should be changed immediately, so the UI is snappy
// however, we're calling the debounced action below, because the child popup should open with delay
popups = this.state.popups;
popups[hoverData.popupIndex].selectedIndex = hoverData.itemIndex;
this.setState({
popups: popups
});
this.processInActionDebounced(hoverData, false);
}
}, {
key: 'onMouseOut',
value: function onMouseOut(ray) {
var hoverData = _hoverDataBuilder2.default.build(this.state.popups, ray);
if (hoverData) {
this.processOutActionDebounced(hoverData);
}
}
/**
* Closes the menu if clicked outside of the menu
*/
}, {
key: 'onMouseDownOutside',
value: function onMouseDownOutside() {
this.closeMenu();
}
}, {
key: 'onMouseUpInside',
value: function onMouseUpInside(ray) {
var hoverData = _hoverDataBuilder2.default.build(this.state.popups, ray);
if (hoverData) {
this.onItemClick(hoverData);
}
}
}, {
key: 'onTouchStart',
value: function onTouchStart(ray) {
var hoverData = _hoverDataBuilder2.default.build(this.state.popups, ray);
if (hoverData) {
this.processInAction(hoverData, false);
}
}
}, {
key: 'onTouchEnd',
value: function onTouchEnd(ray) {
var hoverData = _hoverDataBuilder2.default.build(this.state.popups, ray);
if (hoverData) {
this.onItemClick(hoverData);
}
}
}, {
key: 'onTouchStartOutside',
value: function onTouchStartOutside(ray) {
this.closeMenu();
}
}, {
key: 'onItemClick',
value: function onItemClick(hoverData) {
var self = this;
this.processInAction(hoverData, true);
if (hoverData.isLeafNode() && !hoverData.isPersistant()) {
// leaf node
// defer and allow item handlers to be executed
_lodash2.default.defer(function () {
self.closeMenu();
});
}
this.props.onItemClick(hoverData);
}
//</editor-fold>
//<editor-fold desc="Actions">
/**
* Shows/hides menu
* @param visible
*/
}, {
key: 'setMenuVisibility',
value: function setMenuVisibility(visible) {
var visibilityChanging = this.state.visible !== visible,
isOpening = visibilityChanging && visible,
isClosing = visibilityChanging && !visible,
self = this,
popups;
if (!visibilityChanging) {
return;
}
if (isOpening) {
popups = this.createPopup(this.props.items, true);
} else if (isClosing) {
popups = this.removePopups(0);
}
this.setState({
visible: visible,
popups: popups
}, function () {
if (self.state.visible) {
this.props.onOpen();
this.connectToDispatcher();
} else {
this.disconnectFromDispatcher();
}
});
}
/**
* Hides menu
*/
}, {
key: 'closeMenu',
value: function closeMenu() {
this.setMenuVisibility(false);
_MenuEmitter2.default.getInstance().unregisterAllParts();
this.disconnectFromDispatcher();
this.removeInstance();
this.processInActionDebounced.cancel();
this.processOutActionDebounced.cancel();
this.props.onClose();
}
/**
* Handles the menu item hover or click action
* @param hoverData
* @param shouldFireCallback
*/
}, {
key: 'processInAction',
value: function processInAction(hoverData, shouldFireCallback) {
var hoverDataChanged = !hoverData.equals(this.currentHoverData),
childItems,
popups;
if (shouldFireCallback && hoverData.hasCallback()) {
hoverData.fireCallback(hoverData);
}
if (!hoverDataChanged) {
return; // it's a child
}
this.removeChildPopups(hoverData.popupIndex);
//if (hoverData.isSiblingOf(this.currentHoverData)) {
// this.removeChildPopups(hoverData.popupIndex);
//}
// set current hover data
this.currentHoverData = hoverData;
// process child items if exist
childItems = hoverData.getChildItems();
if (!childItems) {
return;
}
popups = this.createPopup(childItems);
this.setState({
popups: popups
});
this.props.onItemMouseEnter(hoverData);
}
}, {
key: 'processOutAction',
value: function processOutAction(hoverData) {
var hoverDataChanged = !hoverData.equals(this.currentHoverData);
if (!hoverDataChanged) {
return; // it's a child
}
if (!hoverData || // if mouse off menu
hoverData && this.shouldRemoveChildPopups(this.currentHoverData, hoverData)) {
// remove child popups if hovering over another menu item in the same popup
// or hovering the parent popup
//this.removeChildPopups(hoverData.popupIndex); // Complex Mac menu behaviour
this.props.onItemMouseLeave(hoverData);
}
}
//</editor-fold>
//<editor-fold desc="Create/remove popups">
/**
* Removes descendants of a popup specified with popupId and sets the state
* @param popupId
* @param callback
*/
}, {
key: 'removeChildPopups',
value: function removeChildPopups(index) {
var popups = this.removePopups(index + 1);
this.setState({
popups: popups
});
}
/**
* Removes popups with an index greater than or equal to startIndex
* @param startIndex
*/
}, {
key: 'removePopups',
value: function removePopups(startIndex) {
var popups = startIndex === 0 ? [] : this.state.popups.slice(0, startIndex);
return popups;
}
}, {
key: 'createPopup',
value: function createPopup(items, clean) {
if (!items) {
return;
}
var popups = clean ? [] : this.state.popups.slice(0),
id = POPUP_ID_PREFIX + popups.length;
popups.push({
id: id,
items: items
});
return popups;
}
//</editor-fold>
//<editor-fold desc="React">
}, {
key: 'render',
value: function render() {
var level = 0,
self = this,
alignTo,
hints,
popup,
popups = this.state.popups.map(function (data) {
alignTo = self.props.alignToFunc.call(self, level), hints = self.props.hints.call(self, level), popup = _react2.default.createElement(
_reactLiberator2.default,
{
key: 'liberator-popup-' + level,
layer: self.props.layer,
layerId: self.props.layerId,
autoCleanup: self.props.autoCleanup,
onActivate: self.activateHandler },
_react2.default.createElement(_MenuPopup2.default, {
config: self.props.config,
classPrefix: self.props.classPrefix,
key: POPUP_ID_PREFIX + data.id,
popupId: data.id,
items: self.state.popups[level].items,
popupFactory: self.popupFactory,
itemFactory: self.itemFactory,
aligner: self.props.aligner,
alignTo: alignTo,
hints: hints,
useOffset: level !== 0,
selectedIndex: data.selectedIndex })
);
level++;
return popup;
});
return _react2.default.createElement(
'div',
{ className: 'menu' },
popups
);
}
}, {
key: 'componentDidMount',
value: function componentDidMount() {
if (this.props.autoCloseOtherMenuInstances) {
this.closeOtherMenuInstances();
instances = [];
}
this.setMenuVisibility(true);
instances.push(this);
}
}, {
key: 'componentWillUpdate',
value: function componentWillUpdate(nextProps, nextState) {
if (nextState.visible) {
this.connectToDispatcher();
} else {
this.disconnectFromDispatcher();
}
}
}, {
key: 'activateHandler',
value: function activateHandler(e) {
layerElement = e.layer;
_MenuEmitter2.default.getInstance().registerPart(layerElement, false);
}
//</editor-fold>
//<editor-fold desc="Helper">
/**
* Returns true if child popups should be removed
* The decision is made based on depth of currently hovered popup
* @param hoverData
* @param popupId
* @returns {*}
*/
}, {
key: 'shouldRemoveChildPopups',
value: function shouldRemoveChildPopups(previousHoverData, hoverData) {
return previousHoverData && previousHoverData.popupIndex >= hoverData.popupIndex;
}
//</editor-fold>
}]);
return Menu;
}(_react.Component);
//<editor-fold desc="Props">
exports.default = Menu;
Menu.propTypes = {
config: _react2.default.PropTypes.object, // config object visiting each menu item
classPrefix: _react2.default.PropTypes.string, // CSS class prefix for all the classes used by this menu
items: _react2.default.PropTypes.array.isRequired, // menu items (data)
renderers: _react2.default.PropTypes.object, // item renderers
mouseEnterDelay: _react2.default.PropTypes.number,
mouseLeaveDelay: _react2.default.PropTypes.number,
autoCloseOtherMenuInstances: _react2.default.PropTypes.bool, // should opening of a menu close other (currently open) menu instances
onOpen: _react2.default.PropTypes.func,
onClose: _react2.default.PropTypes.func,
onItemMouseEnter: _react2.default.PropTypes.func,
onItemMouseLeave: _react2.default.PropTypes.func,
onItemClick: _react2.default.PropTypes.func,
hints: _react2.default.PropTypes.func,
alignToFunc: _react2.default.PropTypes.func,
layer: _react2.default.PropTypes.node,
layerId: _react2.default.PropTypes.string,
autoCleanup: _react2.default.PropTypes.bool // Liberator's empty layer auto cleanup
};
Menu.defaultProps = {
config: {},
classPrefix: '',
items: [],
aligner: new _Aligner2.default(),
mouseEnterDelay: MOUSE_ENTER_DELAY,
mouseLeaveDelay: MOUSE_LEAVE_DELAY,
autoCloseOtherMenuInstances: true,
onOpen: function onOpen() {},
onClose: function onClose() {},
onItemMouseEnter: function onItemMouseEnter() {},
onItemMouseLeave: function onItemMouseLeave() {},
onItemClick: function onItemClick() {},
hints: HINTS,
alignToFunc: ALIGN_TO,
layer: null,
layerId: DEFAULT_LAYER_ID,
autoCleanup: true
};
//</editor-fold>