UNPKG

react-data-menu

Version:

Smart data-driven menu rendered in an overlay

343 lines (280 loc) 12.6 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); 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 _click = require('./../util/click'); var _click2 = _interopRequireDefault(_click); 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 EmitterPlug = require('raycast-dom').EmitterPlug.default; var TAP_AND_HOLD_INTERVAL = 250; var parts = [], instance, touchTimeout; /** * Checks if any of the parts is within the clicked ray * @param ray The Ray to check * @param includeToggleParts Should we treat toggle parts as parts menu parts * @returns {boolean} */ function isMenuPartClicked(ray, includeToggleParts) { return _lodash2.default.some(parts, function (part) { var shouldIncludeThisPart = includeToggleParts || !part.isToggle; return shouldIncludeThisPart && ray.intersects(part.element); }); } /** * Checks if any of the toggle parts is within the clicked ray * @param ray The Ray to check * @returns {boolean} */ function isTogglePartClicked(ray) { return _lodash2.default.some(parts, function (part) { return part.isToggle && ray.intersects(part.element); }); } /** * Subscribes to browser events (click, contextmenu, touchstart, touchend, resize and scroll) * Dispatches 3 types of events - used by the menu system - by registering handlers and firing them * It basically *converts* browser events to another type of events * The choice of triggered handlers depends of: * 1. is the menu currently on screen * 2. do we click inside or outside of the menu * 3. do we click/contextmenu or tap/tap-and-hold */ var MenuEmitter = function (_EmitterPlug) { _inherits(MenuEmitter, _EmitterPlug); _createClass(MenuEmitter, null, [{ key: 'getInstance', //<editor-fold desc="Singleton"> value: function getInstance() { if (!instance) { instance = new MenuEmitter(); } return instance; } //</editor-fold> //<editor-fold desc="Constructor"> }]); function MenuEmitter() { _classCallCheck(this, MenuEmitter); var _this = _possibleConstructorReturn(this, (MenuEmitter.__proto__ || Object.getPrototypeOf(MenuEmitter)).call(this)); _this.dispatchInteraction = _this.dispatchInteraction.bind(_this); _this.dispatchClose = _this.dispatchClose.bind(_this); _this.handlers = { onMouseOver: _this.onMouseOver.bind(_this), onMouseOut: _this.onMouseOut.bind(_this), onMouseDown: _this.onMouseDown.bind(_this), onMouseUp: _this.onMouseUp.bind(_this), onContextMenu: _this.onContextMenu.bind(_this), onTouchStart: _this.onTouchStart.bind(_this), onTouchEnd: _this.onTouchEnd.bind(_this), onResize: _this.dispatchClose, onScroll: _this.dispatchClose }; return _this; } //</editor-fold> //<editor-fold desc="Menu part registration"> /** * Registers menu part * This is used for differentiating between clicking the menu and outside of the menu * @param element */ _createClass(MenuEmitter, [{ key: 'registerPart', value: function registerPart(element, isToggle) { parts.push({ element: element, isToggle: isToggle }); } /** * Unregisters all menu parts */ }, { key: 'unregisterAllParts', value: function unregisterAllParts() { parts = []; } //</editor-fold> //<editor-fold desc="Evaluation and firing"> /** * @param handlerName * @param ray Emitter ray * @param closeMenu Should we close the menu * @returns {boolean} True if happened outside */ }, { key: 'dispatchInteraction', value: function dispatchInteraction(handlerName, ray, closeMenu) { // 1. close the current menu if needed if (closeMenu) { this.dispatchClose(ray); } // 2. fire the requested handler this.emit(handlerName, ray); } /** * Dispatches the close event */ }, { key: 'dispatchClose', value: function dispatchClose(ray) { this.unregisterAllParts(); this.emit(MenuEmitter.ON_CLOSE); } //</editor-fold> //<editor-fold desc="Mouse"> }, { key: 'onMouseDown', value: function onMouseDown(ray) { this.isMenuCurrentlyOpen = !parts.length; var isInside; if (!_click2.default.isGhostClick(ray)) { // avoid ghost 'click' event on touch devices // we're ignoring toggle parts here // for instance, if dropdown button is in toggleMode, it is a toggle part // if the menu is open and we click the button, the menu should close isInside = isMenuPartClicked(ray, false); if (isInside) { this.dispatchInteraction(MenuEmitter.ON_MOUSE_DOWN_INSIDE, ray); } else if (!this.isMenuCurrentlyOpen) { this.dispatchInteraction(MenuEmitter.ON_MOUSE_DOWN_OUTSIDE, ray, true); } } } /** * Fired on document body mouse up * If we're on touch interface - do nothing * @param e */ }, { key: 'onMouseUp', value: function onMouseUp(ray) { var isInside; if (!_click2.default.isGhostClick(ray)) { // avoid ghost 'click' event on touch devices // we're ignoring toggle parts here // we're checking only if this was mouseup inside, which would trigger an item when opening the menu with click-and-drag isInside = isMenuPartClicked(ray, false); if (isInside) { this.dispatchInteraction(MenuEmitter.ON_MOUSE_UP_INSIDE, ray); } } } /** * Context menu handler * Inside our app root, we will prevent default * However, here we'll dispatch ON_CONTEXT_MENU_INSIDE or ON_CONTEXT_MENU_OUTSIDE * The menu takes care of preventing default when ON_CONTEXT_MENU_INSIDE * The app takes care of preventing default when ON_CONTEXT_MENU_OUTSIDE * @param e */ }, { key: 'onContextMenu', value: function onContextMenu(ray) { var isInside; if (_click2.default.isGhostClick(ray)) { ray.preventDefault(); // avoid ghost 'contextmenu' event on touch devices } else { isInside = isMenuPartClicked(ray, false); if (isInside) { this.dispatchInteraction(MenuEmitter.ON_CONTEXT_MENU_INSIDE, ray); } else { this.dispatchInteraction(MenuEmitter.ON_CONTEXT_MENU_OUTSIDE, ray); } } } }, { key: 'onMouseOver', value: function onMouseOver(ray) { this.createRayAndEmit(MenuEmitter.ON_MOUSE_OVER, document, ray); } }, { key: 'onMouseOut', value: function onMouseOut(ray) { this.createRayAndEmit(MenuEmitter.ON_MOUSE_OUT, document, ray); } //</editor-fold> //<editor-fold desc="Touch"> /** * Fires on document body touchstart * We're switching to touch mode upon each touch * onClick handler checks if we're in touch mode and does not fire (preventing ghost clicks) * Ghost clicks: http://ariatemplates.com/blog/2014/05/ghost-clicks-in-mobile-browsers/ * @param e */ }, { key: 'onTouchStart', value: function onTouchStart(ray) { var self = this, touch = ray.e.changedTouches[0], isInside; ray.position = { x: touch.clientX, y: touch.clientY }; isInside = isMenuPartClicked(ray, false); if (isInside) { // on tap, trigger the click handler this.dispatchInteraction(MenuEmitter.ON_TOUCH_START_INSIDE, ray); } else { this.dispatchInteraction(MenuEmitter.ON_TOUCH_START_OUTSIDE, ray, true); // after a delay (tap and hold) trigger the context menu handler touchTimeout = setTimeout(function () { // we're producing the 'onContextMenu' event on tap-and-hold // because of that, we might have tapped the drop-down button, which opened the menu // we're still within this timeout interval, waiting to dispatch ON_CONTEXT_MENU // however, if the button is in toggle mode, this action would close the menu // since we don't want this to happen, we are ignoring the toggle parts here isInside = isMenuPartClicked(ray, true); // include toggle parts if (!isInside) { self.dispatchInteraction(MenuEmitter.ON_CONTEXT_MENU_OUTSIDE, ray, true); // close menu } }, TAP_AND_HOLD_INTERVAL); } } /** * Fires on document body touchend * @param e */ }, { key: 'onTouchEnd', value: function onTouchEnd(ray) { var touch = ray.e.changedTouches[0], isInside; ray.position = { x: touch.clientX, y: touch.clientY }; // reset the tap-and-hold timer clearTimeout(touchTimeout); isInside = isMenuPartClicked(ray, false); if (isInside) { this.dispatchInteraction(MenuEmitter.ON_TOUCH_END_INSIDE, ray); } } //</editor-fold> }]); return MenuEmitter; }(EmitterPlug); //<editor-fold desc="Constants"> exports.default = MenuEmitter; MenuEmitter.ON_MOUSE_OVER = 'onMouseOver'; // for opening child popups MenuEmitter.ON_MOUSE_OUT = 'onMouseOut'; // for closing child popups MenuEmitter.ON_TOUCH_START_INSIDE = 'onTouchStart'; MenuEmitter.ON_TOUCH_END_INSIDE = 'onTouchEnd'; MenuEmitter.ON_TOUCH_START_OUTSIDE = 'onTouchStartOutside'; MenuEmitter.ON_MOUSE_UP_INSIDE = 'onMouseUpInside'; // when menu part clicked MenuEmitter.ON_MOUSE_DOWN_INSIDE = 'onMouseDownInside'; // when menu part clicked MenuEmitter.ON_MOUSE_DOWN_OUTSIDE = 'onMouseDownOutside'; // when clicked outside of the menu MenuEmitter.ON_CONTEXT_MENU_INSIDE = 'onContextMenuInside'; // when menu part right-clicked MenuEmitter.ON_CONTEXT_MENU_OUTSIDE = 'onContextMenuOutside'; // when right-clicked outside of the menu MenuEmitter.ON_CLOSE = 'onClose'; // when menu has to close //</editor-fold>