UNPKG

react-data-menu

Version:

Smart data-driven menu rendered in an overlay

474 lines (406 loc) 14.2 kB
import _ from 'lodash'; import React, { Component } from 'react'; import Aligner from './../util/Aligner.js'; import { DefaultRenderers } from './../renderers/defaultRenderers.js'; import Dom from './../util/Dom'; import HoverData from './../util/hoverData'; import HoverDataBuilder from './../util/hoverDataBuilder'; import MenuEmitter from './../emitters/MenuEmitter.js'; import MenuItemFactory from './MenuItemFactory.js'; import MenuPopup from './MenuPopup'; import MenuPopupFactory from './MenuPopupFactory.js'; import Liberator from 'react-liberator'; export const POPUP_ID_PREFIX = 'menu-popup-'; const DEFAULT_LAYER_ID = 'react-data-menu-popup', MOUSE_LEAVE_DELAY = 100, MOUSE_ENTER_DELAY = 200, HINTS = function() { return ['es', 'em', 'ee', 'ws', 'wm', 'we']; }, ALIGN_TO = function(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; export default class Menu extends Component { //<editor-fold desc="Constructor"> constructor(props) { super(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 = _.debounce(this.processInAction.bind(this), MOUSE_ENTER_DELAY); this.processOutActionDebounced = _.debounce(this.processOutAction.bind(this), MOUSE_LEAVE_DELAY); this.popupFactory = new MenuPopupFactory(this.props.classPrefix); this.itemFactory = new MenuItemFactory(_.assign(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) }; } //</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.) */ closeOtherMenuInstances() { var self = this; _.forEach(instances, function(instance) { if (instance !== self) { instance.closeMenu(); } }); } /** * Removes this menu from instances array */ removeInstance() { var self = this; _.remove(instances, function(instance) { return self === instance; }); } //</editor-fold> //<editor-fold desc="Emitter subscribe / unsubscribe"> /** * Event subscription * Happens when menu becomes visible */ connectToDispatcher() { MenuEmitter.getInstance().connect(this.handlers); } /** * Unsubscribing from events * Happens when menu becomes hidden */ disconnectFromDispatcher() { MenuEmitter.getInstance().disconnect(this.handlers); } //</editor-fold> //<editor-fold desc="Raycast"> /** * Fires on context menu inside of the menu * @param ray */ onContextMenuInside(ray) { ray.preventDefault(); // prevent default menu } onMouseOver(ray) { var hoverData = HoverDataBuilder.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 }); this.processInActionDebounced(hoverData, false); } onMouseOut(ray) { var hoverData = HoverDataBuilder.build(this.state.popups, ray); if (hoverData) { this.processOutActionDebounced(hoverData); } } /** * Closes the menu if clicked outside of the menu */ onMouseDownOutside() { this.closeMenu(); } onMouseUpInside(ray) { var hoverData = HoverDataBuilder.build(this.state.popups, ray); if (hoverData) { this.onItemClick(hoverData); } } onTouchStart(ray) { var hoverData = HoverDataBuilder.build(this.state.popups, ray); if (hoverData) { this.processInAction(hoverData, false); } } onTouchEnd(ray) { var hoverData = HoverDataBuilder.build(this.state.popups, ray); if (hoverData) { this.onItemClick(hoverData); } } onTouchStartOutside(ray) { this.closeMenu(); } onItemClick(hoverData) { var self = this; this.processInAction(hoverData, true); if (hoverData.isLeafNode() && !hoverData.isPersistant()) { // leaf node // defer and allow item handlers to be executed _.defer(function() { self.closeMenu(); }); } this.props.onItemClick(hoverData); } //</editor-fold> //<editor-fold desc="Actions"> /** * Shows/hides menu * @param visible */ 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, popups }, function() { if (self.state.visible) { this.props.onOpen(); this.connectToDispatcher(); } else { this.disconnectFromDispatcher(); } }); } /** * Hides menu */ closeMenu() { this.setMenuVisibility(false); MenuEmitter.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 */ 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 }); this.props.onItemMouseEnter(hoverData); } 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 */ removeChildPopups(index) { var popups = this.removePopups(index + 1); this.setState({ popups }); } /** * Removes popups with an index greater than or equal to startIndex * @param startIndex */ removePopups(startIndex) { var popups = startIndex === 0 ? [] : this.state.popups.slice(0, startIndex); return popups; } createPopup(items, clean) { if (!items) { return; } var popups = clean ? [] : this.state.popups.slice(0), id = POPUP_ID_PREFIX + popups.length; popups.push({ id, items }); return popups; } //</editor-fold> //<editor-fold desc="React"> 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 = ( <Liberator key={'liberator-popup-' + level} layer={self.props.layer} layerId={self.props.layerId} autoCleanup={self.props.autoCleanup} onActivate={self.activateHandler}> <MenuPopup 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} /> </Liberator> ); level++; return popup; }); return ( <div className='menu' > {popups} </div> ); } componentDidMount() { if (this.props.autoCloseOtherMenuInstances) { this.closeOtherMenuInstances(); instances = []; } this.setMenuVisibility(true); instances.push(this); } componentWillUpdate(nextProps, nextState) { if (nextState.visible) { this.connectToDispatcher(); } else { this.disconnectFromDispatcher(); } } activateHandler(e) { layerElement = e.layer; MenuEmitter.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 {*} */ shouldRemoveChildPopups(previousHoverData, hoverData) { return previousHoverData && previousHoverData.popupIndex >= hoverData.popupIndex; } //</editor-fold> } //<editor-fold desc="Props"> Menu.propTypes = { config: React.PropTypes.object, // config object visiting each menu item classPrefix: React.PropTypes.string, // CSS class prefix for all the classes used by this menu items: React.PropTypes.array.isRequired, // menu items (data) renderers: React.PropTypes.object, // item renderers mouseEnterDelay: React.PropTypes.number, mouseLeaveDelay: React.PropTypes.number, autoCloseOtherMenuInstances: React.PropTypes.bool, // should opening of a menu close other (currently open) menu instances onOpen: React.PropTypes.func, onClose: React.PropTypes.func, onItemMouseEnter: React.PropTypes.func, onItemMouseLeave: React.PropTypes.func, onItemClick: React.PropTypes.func, hints: React.PropTypes.func, alignToFunc: React.PropTypes.func, layer: React.PropTypes.node, layerId: React.PropTypes.string, autoCleanup: React.PropTypes.bool // Liberator's empty layer auto cleanup }; Menu.defaultProps = { config: {}, classPrefix: '', items: [], aligner: new Aligner(), mouseEnterDelay: MOUSE_ENTER_DELAY, mouseLeaveDelay: MOUSE_LEAVE_DELAY, autoCloseOtherMenuInstances: true, onOpen() {}, onClose() {}, onItemMouseEnter() {}, onItemMouseLeave() {}, onItemClick() {}, hints: HINTS, alignToFunc: ALIGN_TO, layer: null, layerId: DEFAULT_LAYER_ID, autoCleanup: true }; //</editor-fold>