From 8dbd6b31bbe02af09a4b3ece48d6078545c0f53f Mon Sep 17 00:00:00 2001 From: Alexander Fedyashov Date: Wed, 20 Sep 2017 18:03:06 +0300 Subject: [PATCH 1/2] feat(eventStack): support for different targets --- src/addons/Portal/Portal.js | 12 +- src/behaviors/Visibility/Visibility.js | 11 +- .../EventTarget.js} | 21 ++- src/lib/eventStack/eventStack.js | 61 +++++++ src/lib/eventStack/index.js | 1 + src/lib/eventStack/normalizeTarget.js | 13 ++ src/modules/Popup/Popup.js | 18 ++- src/modules/Sticky/Sticky.js | 9 +- test/specs/lib/eventStack-test.js | 124 --------------- test/specs/lib/eventStack/EventTarget-test.js | 149 ++++++++++++++++++ test/specs/lib/eventStack/eventStack-test.js | 72 +++++++++ .../lib/eventStack/normalizeTarget-test.js | 41 +++++ 12 files changed, 372 insertions(+), 160 deletions(-) rename src/lib/{eventStack.js => eventStack/EventTarget.js} (86%) create mode 100644 src/lib/eventStack/eventStack.js create mode 100644 src/lib/eventStack/index.js create mode 100644 src/lib/eventStack/normalizeTarget.js delete mode 100644 test/specs/lib/eventStack-test.js create mode 100644 test/specs/lib/eventStack/EventTarget-test.js create mode 100644 test/specs/lib/eventStack/eventStack-test.js create mode 100644 test/specs/lib/eventStack/normalizeTarget-test.js diff --git a/src/addons/Portal/Portal.js b/src/addons/Portal/Portal.js index f983367933..f61bd518b8 100644 --- a/src/addons/Portal/Portal.js +++ b/src/addons/Portal/Portal.js @@ -349,8 +349,8 @@ class Portal extends Component { // when re-rendering, first remove listeners before re-adding them to the new node if (this.portalNode) { - this.portalNode.removeEventListener('mouseleave', this.handlePortalMouseLeave) - this.portalNode.removeEventListener('mouseenter', this.handlePortalMouseEnter) + eventStack.unsub('mouseleave', this.handlePortalMouseLeave, { target: this.portalNode }) + eventStack.unsub('mouseenter', this.handlePortalMouseEnter, { target: this.portalNode }) } ReactDOM.unstable_renderSubtreeIntoContainer( @@ -360,8 +360,8 @@ class Portal extends Component { () => { this.portalNode = this.rootNode.firstElementChild - this.portalNode.addEventListener('mouseleave', this.handlePortalMouseLeave) - this.portalNode.addEventListener('mouseenter', this.handlePortalMouseEnter) + eventStack.sub('mouseleave', this.handlePortalMouseLeave, { target: this.portalNode }) + eventStack.sub('mouseenter', this.handlePortalMouseEnter, { target: this.portalNode }) }, ) } @@ -397,8 +397,8 @@ class Portal extends Component { ReactDOM.unmountComponentAtNode(this.rootNode) this.rootNode.parentNode.removeChild(this.rootNode) - this.portalNode.removeEventListener('mouseleave', this.handlePortalMouseLeave) - this.portalNode.removeEventListener('mouseenter', this.handlePortalMouseEnter) + eventStack.unsub('mouseleave', this.handlePortalMouseLeave, { target: this.portalNode }) + eventStack.unsub('mouseenter', this.handlePortalMouseEnter, { target: this.portalNode }) this.rootNode = null this.portalNode = null diff --git a/src/behaviors/Visibility/Visibility.js b/src/behaviors/Visibility/Visibility.js index 547cda644b..99f53dbfe8 100644 --- a/src/behaviors/Visibility/Visibility.js +++ b/src/behaviors/Visibility/Visibility.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' import { + eventStack, customPropTypes, getElementType, getUnhandledProps, @@ -171,17 +172,15 @@ export default class Visibility extends Component { } componentDidMount() { - if (!isBrowser) return - const { context } = this.props - context.addEventListener('scroll', this.handleScroll) + + eventStack.sub('scroll', this.handleScroll, { target: context }) } componentWillUnmount() { - if (!isBrowser) return - const { context } = this.props - context.removeEventListener('scroll', this.handleScroll) + + eventStack.unsub('scroll', this.handleScroll, { target: context }) } execute = (callback, name) => { diff --git a/src/lib/eventStack.js b/src/lib/eventStack/EventTarget.js similarity index 86% rename from src/lib/eventStack.js rename to src/lib/eventStack/EventTarget.js index 2a70839a85..20d376e5c2 100644 --- a/src/lib/eventStack.js +++ b/src/lib/eventStack/EventTarget.js @@ -1,10 +1,13 @@ import _ from 'lodash' -import isBrowser from './isBrowser' -class EventStack { +export default class EventTarget { _handlers = {} _pools = {} + constructor(target) { + this.target = target + } + // ------------------------------------ // Utils // ------------------------------------ @@ -32,7 +35,7 @@ class EventStack { if (_.has(this._handlers, name)) return const handler = this._emit(name) - document.addEventListener(name, handler) + this.target.addEventListener(name, handler) this._handlers[name] = handler } @@ -40,7 +43,7 @@ class EventStack { if (_.some(this._pools, name)) return const { [name]: handler } = this._handlers - document.removeEventListener(name, handler) + this.target.removeEventListener(name, handler) delete this._handlers[name] } @@ -48,9 +51,9 @@ class EventStack { // Pub/sub // ------------------------------------ - sub = (name, handlers, pool = 'default') => { - if (!isBrowser) return + empty = () => _.isEmpty(this._handlers) + sub = (name, handlers, pool = 'default') => { const events = _.uniq([ ..._.get(this._pools, `${pool}.${name}`, []), ...this._normalize(handlers), @@ -61,8 +64,6 @@ class EventStack { } unsub = (name, handlers, pool = 'default') => { - if (!isBrowser) return - const events = _.without( _.get(this._pools, `${pool}.${name}`, []), ...this._normalize(handlers), @@ -77,7 +78,3 @@ class EventStack { this._unlisten(name) } } - -const instance = new EventStack() - -export default instance diff --git a/src/lib/eventStack/eventStack.js b/src/lib/eventStack/eventStack.js new file mode 100644 index 0000000000..7b373ac06e --- /dev/null +++ b/src/lib/eventStack/eventStack.js @@ -0,0 +1,61 @@ +import _ from 'lodash' + +import isBrowser from '../isBrowser' +import EventTarget from './EventTarget' +import normalizeTarget from './normalizeTarget' + +class EventStack { + _eventTargets = {} + _targets = [] + + // ------------------------------------ + // Target utils + // ------------------------------------ + + _find = target => { + const normalized = normalizeTarget(target) + let index = this._targets.indexOf(normalized) + + if(index !== -1) return this._eventTargets[index] + + index = this._targets.push(normalized) - 1 + this._eventTargets[index] = new EventTarget(normalized) + + return this._eventTargets[index] + } + + _remove = (target) => { + const normalized = normalizeTarget(target) + const index = this._targets.indexOf(normalized) + + this._targets = _.without(this._targets, normalized) + delete this._eventTargets[index] + } + + // ------------------------------------ + // Pub/sub + // ------------------------------------ + + sub = (name, handlers, options = {}) => { + if (!isBrowser) return + + const { target = document, pool = 'default' } = options + const eventTarget = this._find(target) + + eventTarget.sub(name, handlers, pool) + } + + unsub = (name, handlers, options = {}) => { + if (!isBrowser) return + + const { target = document, pool = 'default' } = options + const eventTarget = this._find(target) + + eventTarget.unsub(name, handlers, pool) + if(eventTarget.empty()) this._remove(target) + } +} + +const instance = new EventStack() + +export default instance diff --git a/src/lib/eventStack/index.js b/src/lib/eventStack/index.js new file mode 100644 index 0000000000..47510440a2 --- /dev/null +++ b/src/lib/eventStack/index.js @@ -0,0 +1 @@ +export default from './eventStack' diff --git a/src/lib/eventStack/normalizeTarget.js b/src/lib/eventStack/normalizeTarget.js new file mode 100644 index 0000000000..cbbefd11fb --- /dev/null +++ b/src/lib/eventStack/normalizeTarget.js @@ -0,0 +1,13 @@ +/** + * Normalizes `target` for EventStack, because `target` can be passed as `boolean` or `string`. + * + * @param {boolean|string|HTMLElement|Window} target Value for normalization. + * @return {HTMLElement|Window} A DOM node. + */ +const normalizeTarget = (target) => { + if(target === 'document') return document + if(target === 'window') return window + return target || document +} + +export default normalizeTarget \ No newline at end of file diff --git a/src/modules/Popup/Popup.js b/src/modules/Popup/Popup.js index 64ee04a019..3be071cce8 100644 --- a/src/modules/Popup/Popup.js +++ b/src/modules/Popup/Popup.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' import { + eventStack, childrenUtils, customPropTypes, getElementType, @@ -276,7 +277,8 @@ export default class Popup extends Component { hideOnScroll = () => { this.setState({ closed: true }) - window.removeEventListener('scroll', this.hideOnScroll) + + eventStack.unsub('scroll', this.hideOnScroll, { target: window }) setTimeout(() => this.setState({ closed: false }), 50) } @@ -296,18 +298,18 @@ export default class Popup extends Component { handlePortalMount = (e) => { debug('handlePortalMount()') - if (this.props.hideOnScroll) { - window.addEventListener('scroll', this.hideOnScroll) - } + const { hideOnScroll } = this.props - const { onMount } = this.props - if (onMount) onMount(e, this.props) + if (hideOnScroll) eventStack.sub('scroll', this.hideOnScroll, { target: window }) + _.invoke(this.props, 'onMount', e, this.props) } handlePortalUnmount = (e) => { debug('handlePortalUnmount()') - const { onUnmount } = this.props - if (onUnmount) onUnmount(e, this.props) + const { hideOnScroll } = this.props + + if (hideOnScroll) eventStack.unsub('scroll', this.hideOnScroll, { target: window }) + _.invoke(this.props, 'onUnmount', e, this.props) } handlePopupRef = (popupRef) => { diff --git a/src/modules/Sticky/Sticky.js b/src/modules/Sticky/Sticky.js index e8db79619a..6013aa1024 100644 --- a/src/modules/Sticky/Sticky.js +++ b/src/modules/Sticky/Sticky.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' import { + eventStack, customPropTypes, getElementType, getUnhandledProps, @@ -89,17 +90,17 @@ export default class Sticky extends Component { componentDidMount() { if (!isBrowser) return - const { scrollContext } = this.props + this.handleUpdate() - scrollContext.addEventListener('scroll', this.handleUpdate) + eventStack.sub('scroll', this.handleUpdate, { target: scrollContext }) } componentWillUnmount() { if (!isBrowser) return - const { scrollContext } = this.props - scrollContext.removeEventListener('scroll', this.handleUpdate) + + eventStack.unsub('scroll', this.handleUpdate, { target: scrollContext }) } // ---------------------------------------- diff --git a/test/specs/lib/eventStack-test.js b/test/specs/lib/eventStack-test.js deleted file mode 100644 index a997fc4fb2..0000000000 --- a/test/specs/lib/eventStack-test.js +++ /dev/null @@ -1,124 +0,0 @@ -import _ from 'lodash' - -import { eventStack } from 'src/lib' -import { domEvent, sandbox } from 'test/utils' - -describe('eventStack', () => { - afterEach(() => { - eventStack._pools = {} - _.forEach(_.keys(eventStack._handlers), name => eventStack._unlisten(name)) - }) - - describe('pub', () => { - it('adds a single', () => { - const first = sandbox.spy() - - eventStack.sub('click', first) - domEvent.click(document.body) - - first.should.have.been.calledOnce() - }) - - it('adds multiple', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', first) - eventStack.sub('click', second) - domEvent.click(document.body) - - first.should.have.been.calledOnce() - second.should.have.been.calledOnce() - }) - - it('adds multiple with array', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', [first, second]) - domEvent.click(document.body) - - first.should.have.been.calledOnce() - second.should.have.been.calledOnce() - }) - - it('adds only unique', () => { - const first = sandbox.spy() - - eventStack.sub('click', [first, first]) - eventStack.sub('click', [first, first]) - - domEvent.click(document.body) - first.should.have.been.calledOnce() - }) - - it('handles multiple pools', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', first) - eventStack.sub('click', second, 'another') - domEvent.click(document.body) - - first.should.have.been.calledOnce() - second.should.have.been.calledOnce() - }) - - it('fires only last handler in non-default pool', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', first, 'another') - eventStack.sub('click', second, 'another') - domEvent.click(document.body) - - first.should.not.have.been.called() - second.should.have.been.calledOnce() - }) - }) - - describe('unsub', () => { - it('handles unsubscribe', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', [first, second]) - domEvent.click(document.body) - - eventStack.unsub('click', second) - domEvent.click(document.body) - - first.should.have.been.calledTwice() - second.should.have.been.calledOnce() - }) - - it('handles multiple unsubscribe', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', [first, second]) - domEvent.click(document.body) - - eventStack.unsub('click', [first, second]) - domEvent.click(document.body) - - first.should.have.been.calledOnce() - second.should.have.been.calledOnce() - }) - - it('handles unsubscribe with multiple pools', () => { - const first = sandbox.spy() - const second = sandbox.spy() - - eventStack.sub('click', first) - eventStack.sub('click', second, 'another') - domEvent.click(document.body) - - eventStack.unsub('click', second, 'another') - domEvent.click(document.body) - - first.should.have.been.calledTwice() - second.should.have.been.calledOnce() - }) - }) -}) diff --git a/test/specs/lib/eventStack/EventTarget-test.js b/test/specs/lib/eventStack/EventTarget-test.js new file mode 100644 index 0000000000..0ee51e540a --- /dev/null +++ b/test/specs/lib/eventStack/EventTarget-test.js @@ -0,0 +1,149 @@ +import EventTarget from 'src/lib/eventStack/EventTarget' +import { domEvent, sandbox } from 'test/utils' + +describe('EventTarget', () => { + let eventTarget + + beforeEach(() => { + eventTarget = new EventTarget(document) + }) + + afterEach(() => { + eventTarget = null + }) + + describe('empty', () => { + it('is true by default', () => { + eventTarget.empty() + .should.equal(true) + }) + + it('is false when has handlers', () => { + eventTarget.sub('click', () => {}) + eventTarget.empty() + .should.equal(false) + }) + + it('is true when handlers are removed', () => { + const handler = () => {} + + eventTarget.sub('click', handler) + eventTarget.unsub('click', handler) + eventTarget.empty() + .should.equal(true) + }) + }) + + describe('sub', () => { + it('adds a single', () => { + const first = sandbox.spy() + + eventTarget.sub('click', first) + domEvent.click(document) + + first.should.have.been.calledOnce() + }) + + it('adds multiple', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', first) + eventTarget.sub('click', second) + domEvent.click(document) + + first.should.have.been.calledOnce() + second.should.have.been.calledOnce() + }) + + it('adds multiple with array', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', [first, second]) + domEvent.click(document) + + first.should.have.been.calledOnce() + second.should.have.been.calledOnce() + }) + + it('adds only unique', () => { + const first = sandbox.spy() + + eventTarget.sub('click', [first, first]) + eventTarget.sub('click', [first, first]) + + domEvent.click(document) + first.should.have.been.calledOnce() + }) + + it('handles multiple pools', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', first) + eventTarget.sub('click', second, 'another') + domEvent.click(document) + + first.should.have.been.calledOnce() + second.should.have.been.calledOnce() + }) + + it('fires only last handler in non-default pool', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', first, 'another') + eventTarget.sub('click', second, 'another') + domEvent.click(document) + + first.should.not.have.been.called() + second.should.have.been.calledOnce() + }) + }) + + describe('unsub', () => { + it('handles unsubscribe', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', [first, second]) + domEvent.click(document) + + eventTarget.unsub('click', second) + domEvent.click(document) + + first.should.have.been.calledTwice() + second.should.have.been.calledOnce() + }) + + it('handles multiple unsubscribe', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', [first, second]) + domEvent.click(document) + + eventTarget.unsub('click', [first, second]) + domEvent.click(document) + + first.should.have.been.calledOnce() + second.should.have.been.calledOnce() + }) + + it('handles unsubscribe with multiple pools', () => { + const first = sandbox.spy() + const second = sandbox.spy() + + eventTarget.sub('click', first) + eventTarget.sub('click', second, 'another') + domEvent.click(document) + + eventTarget.unsub('click', second, 'another') + domEvent.click(document) + + first.should.have.been.calledTwice() + second.should.have.been.calledOnce() + }) + }) +}) diff --git a/test/specs/lib/eventStack/eventStack-test.js b/test/specs/lib/eventStack/eventStack-test.js new file mode 100644 index 0000000000..4da75e1c7c --- /dev/null +++ b/test/specs/lib/eventStack/eventStack-test.js @@ -0,0 +1,72 @@ +import { eventStack } from 'src/lib' +import { domEvent, sandbox } from 'test/utils' + +describe('eventStack', () => { + afterEach(() => { + eventStack._eventTargets = {} + eventStack._targets = [] + }) + + describe('sub', () => { + it('subscribes for single target', () => { + const handler = sandbox.spy() + + eventStack.sub('click', handler) + domEvent.click(document) + + handler.should.have.been.calledOnce() + }) + + it('subscribes for custom target', () => { + const handler = sandbox.spy() + const target = document.createElement('div') + + eventStack.sub('click', handler, { target }) + domEvent.click(target) + + handler.should.have.been.calledOnce() + }) + + it('subscribes for multiple targets', () => { + const documentHandler = sandbox.spy() + const windowHandler = sandbox.spy() + + eventStack.sub('click', documentHandler) + eventStack.sub('scroll', windowHandler, { target: window }) + domEvent.click(document) + domEvent.scroll(window) + + documentHandler.should.have.been.calledOnce() + windowHandler.should.have.been.calledOnce() + }) + }) + + describe('unsub', () => { + it('unsubscribes and destroys eventTarget if it is empty', () => { + const handler = sandbox.spy() + + eventStack.sub('click', handler) + domEvent.click(document) + + eventStack.unsub('click', handler) + domEvent.click(document) + + handler.should.have.been.calledOnce() + }) + + it('unsubscribes but leaves eventTarget if it contains handlers', () => { + const clickHandler = sandbox.spy() + const keyHandler = sandbox.spy() + + eventStack.sub('click', clickHandler) + eventStack.sub('keyDown', keyHandler) + domEvent.click(document) + + eventStack.unsub('click', clickHandler) + domEvent.click(document) + + clickHandler.should.have.been.calledOnce() + keyHandler.should.have.not.been.called() + }) + }) +}) diff --git a/test/specs/lib/eventStack/normalizeTarget-test.js b/test/specs/lib/eventStack/normalizeTarget-test.js new file mode 100644 index 0000000000..cfe8bed9d4 --- /dev/null +++ b/test/specs/lib/eventStack/normalizeTarget-test.js @@ -0,0 +1,41 @@ +import normalizeTarget from 'src/lib/eventStack/normalizeTarget' + +describe('normalizeTarget', () => { + describe('document', () => { + it('returns `document` when it passed as string', () => { + normalizeTarget('document') + .should.equal(document) + }) + + it('returns `document` when `false` passed', () => { + normalizeTarget(false) + .should.equal(document) + }) + + it('returns `document` when it passed', () => { + normalizeTarget(document) + .should.equal(document) + }) + }) + + describe('element', () => { + it('returns `element` when it passed', () => { + const element = document.createElement('div') + + normalizeTarget(element) + .should.equal(element) + }) + }) + + describe('window', () => { + it('returns `document` when it passed as string', () => { + normalizeTarget('window') + .should.equal(window) + }) + + it('returns document when it passed', () => { + normalizeTarget(window) + .should.equal(window) + }) + }) +}) From a3caa730bfc9f12fb30bb8a600ebbf9504731045 Mon Sep 17 00:00:00 2001 From: Alexander Fedyashov Date: Wed, 20 Sep 2017 18:24:13 +0300 Subject: [PATCH 2/2] style(eventStack): fix lint issues --- src/lib/eventStack/eventStack.js | 6 +++--- src/lib/eventStack/normalizeTarget.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/eventStack/eventStack.js b/src/lib/eventStack/eventStack.js index 7b373ac06e..ba74bf4f50 100644 --- a/src/lib/eventStack/eventStack.js +++ b/src/lib/eventStack/eventStack.js @@ -12,11 +12,11 @@ class EventStack { // Target utils // ------------------------------------ - _find = target => { + _find = (target) => { const normalized = normalizeTarget(target) let index = this._targets.indexOf(normalized) - if(index !== -1) return this._eventTargets[index] + if (index !== -1) return this._eventTargets[index] index = this._targets.push(normalized) - 1 this._eventTargets[index] = new EventTarget(normalized) @@ -52,7 +52,7 @@ class EventStack { const eventTarget = this._find(target) eventTarget.unsub(name, handlers, pool) - if(eventTarget.empty()) this._remove(target) + if (eventTarget.empty()) this._remove(target) } } diff --git a/src/lib/eventStack/normalizeTarget.js b/src/lib/eventStack/normalizeTarget.js index cbbefd11fb..52b84ba717 100644 --- a/src/lib/eventStack/normalizeTarget.js +++ b/src/lib/eventStack/normalizeTarget.js @@ -5,9 +5,9 @@ * @return {HTMLElement|Window} A DOM node. */ const normalizeTarget = (target) => { - if(target === 'document') return document - if(target === 'window') return window + if (target === 'document') return document + if (target === 'window') return window return target || document } -export default normalizeTarget \ No newline at end of file +export default normalizeTarget