From 3234a2347f0c8412a7971ab3c8d83b0eceb90d4f Mon Sep 17 00:00:00 2001 From: Alexander Fedyashov Date: Thu, 7 Sep 2017 13:28:13 +0300 Subject: [PATCH] feat(Sticky): add `active` prop --- src/modules/Sticky/Sticky.d.ts | 3 + src/modules/Sticky/Sticky.js | 49 ++- test/specs/modules/Sticky/Sticky-test.js | 429 +++++++++++++---------- test/utils/domEvent.js | 17 +- 4 files changed, 304 insertions(+), 194 deletions(-) diff --git a/src/modules/Sticky/Sticky.d.ts b/src/modules/Sticky/Sticky.d.ts index 6229beb5b9..767cac847c 100644 --- a/src/modules/Sticky/Sticky.d.ts +++ b/src/modules/Sticky/Sticky.d.ts @@ -6,6 +6,9 @@ export interface StickyProps { /** An element type to render as (string or function). */ as?: any; + /** A Sticky can be active. */ + active?: boolean; + /** Offset in pixels from the bottom of the screen when fixing element to viewport. */ bottomOffset?: number; diff --git a/src/modules/Sticky/Sticky.js b/src/modules/Sticky/Sticky.js index e8db79619a..167d8b873d 100644 --- a/src/modules/Sticky/Sticky.js +++ b/src/modules/Sticky/Sticky.js @@ -6,8 +6,8 @@ import { customPropTypes, getElementType, getUnhandledProps, - META, isBrowser, + META, } from '../../lib' /** @@ -18,6 +18,9 @@ export default class Sticky extends Component { /** An element type to render as (string or function). */ as: customPropTypes.as, + /** A Sticky can be active. */ + active: PropTypes.bool, + /** Offset in pixels from the bottom of the screen when fixing element to viewport. */ bottomOffset: PropTypes.number, @@ -73,6 +76,7 @@ export default class Sticky extends Component { } static defaultProps = { + active: true, bottomOffset: 0, offset: 0, scrollContext: isBrowser ? window : null, @@ -89,16 +93,46 @@ export default class Sticky extends Component { componentDidMount() { if (!isBrowser) return + const { active } = this.props - const { scrollContext } = this.props - this.handleUpdate() - scrollContext.addEventListener('scroll', this.handleUpdate) + if (active) { + this.handleUpdate() + this.addListener() + } + } + + componentWillReceiveProps({ active: next }) { + const { active: current } = this.props + + if (current === next) return + if (next) { + this.handleUpdate() + this.addListener() + return + } + this.removeListener() } componentWillUnmount() { if (!isBrowser) return + const { active } = this.props + + if (active) this.removeListener() + } + + // ---------------------------------------- + // Events + // ---------------------------------------- + addListener = () => { const { scrollContext } = this.props + + scrollContext.addEventListener('scroll', this.handleUpdate) + } + + removeListener = () => { + const { scrollContext } = this.props + scrollContext.removeEventListener('scroll', this.handleUpdate) } @@ -110,7 +144,6 @@ export default class Sticky extends Component { const { pushing } = this.state this.ticking = false - this.assignRects() if (pushing) { @@ -163,9 +196,6 @@ export default class Sticky extends Component { } } - // Return true if the height of the component is higher than the window - isOversized = () => this.stickyRect.height > window.innerHeight - // Return true when the component reached the bottom of the context didReachContextBottom = () => { const { offset } = this.props @@ -186,6 +216,9 @@ export default class Sticky extends Component { return (this.contextRect.bottom + bottomOffset) > window.innerHeight } + // Return true if the height of the component is higher than the window + isOversized = () => this.stickyRect.height > window.innerHeight + // ---------------------------------------- // Stick helpers // ---------------------------------------- diff --git a/test/specs/modules/Sticky/Sticky-test.js b/test/specs/modules/Sticky/Sticky-test.js index ce42e65af4..49c94f62dc 100644 --- a/test/specs/modules/Sticky/Sticky-test.js +++ b/test/specs/modules/Sticky/Sticky-test.js @@ -1,45 +1,71 @@ import React from 'react' import Sticky from 'src/modules/Sticky/Sticky' -import { sandbox } from 'test/utils' import * as common from 'test/specs/commonTests' +import { domEvent, sandbox } from 'test/utils' + +let contextEl +let wrapper +let positions + +const mockRect = (values = {}) => ({ getBoundingClientRect: () => values }) + +const mockContextEl = (values = {}) => (contextEl = mockRect(values)) + +const mockPositions = ({ bottomOffset = 5, offset = 5, height = 5 } = {}) => (positions = { + bottomOffset, + height, + offset, +}) + +const wrapperMount = (...args) => (wrapper = mount(...args)) // Scroll to the top of the screen -const scrollToTop = (wrapper, contextEl, { bottomOffset, offset, height }) => { +const scrollToTop = () => { + const { bottomOffset, height, offset } = positions const instance = wrapper.instance() - instance.triggerRef = { getBoundingClientRect: () => ({ top: offset }) } - instance.stickyRef = { getBoundingClientRect: () => ({ height, top: offset }) } - // eslint-disable-next-line no-param-reassign - contextEl.getBoundingClientRect = () => ({ bottom: height + offset + bottomOffset }) - window.dispatchEvent(new Event('scroll')) + + instance.triggerRef = mockRect({ top: offset }) + instance.stickyRef = mockRect({ height, top: offset }) + wrapper.setProps({ content: mockRect({ bottom: height + offset + bottomOffset }) }) + + domEvent.scroll(window) } // Scroll until the trigger is not visible -const scrollAfterTrigger = (wrapper, contextEl, { bottomOffset, offset, height }) => { +const scrollAfterTrigger = () => { + const { bottomOffset, height, offset } = positions const instance = wrapper.instance() - instance.triggerRef = { getBoundingClientRect: () => ({ top: offset - 1 }) } - instance.stickyRef = { getBoundingClientRect: () => ({ height }) } - // eslint-disable-next-line no-param-reassign - contextEl.getBoundingClientRect = () => ({ bottom: (window.innerHeight - bottomOffset) + 1 }) - window.dispatchEvent(new Event('scroll')) + + instance.triggerRef = mockRect({ top: offset - 1 }) + instance.stickyRef = mockRect({ height }) + wrapper.setProps({ context: mockRect({ bottom: (window.innerHeight - bottomOffset) + 1 }) }) + + domEvent.scroll(window) } // Scroll until the context bottom is not visible -const scrollAfterContext = (wrapper, contextEl, { offset, height }) => { +const scrollAfterContext = () => { + const { height, offset } = positions const instance = wrapper.instance() - instance.triggerRef = { getBoundingClientRect: () => ({ top: offset - 1 }) } - instance.stickyRef = { getBoundingClientRect: () => ({ height }) } - contextEl.getBoundingClientRect = () => ({ bottom: -1 }) // eslint-disable-line no-param-reassign - window.dispatchEvent(new Event('scroll')) + + instance.triggerRef = mockRect({ top: offset - 1 }) + instance.stickyRef = mockRect({ height }) + wrapper.setProps({ context: mockRect({ bottom: -1 }) }) + + domEvent.scroll(window) } // Scroll to the last part of the context -const scrollToContextBottom = (wrapper, contextEl, { offset, height }) => { +const scrollToContextBottom = () => { + const { height, offset } = positions const instance = wrapper.instance() - instance.triggerRef = { getBoundingClientRect: () => ({ top: offset - 1 }) } - instance.stickyRef = { getBoundingClientRect: () => ({ height }) } - contextEl.getBoundingClientRect = () => ({ bottom: height + 1 }) // eslint-disable-line no-param-reassign - window.dispatchEvent(new Event('scroll')) + + instance.triggerRef = mockRect({ top: offset - 1 }) + instance.stickyRef = mockRect({ height }) + wrapper.setProps({ context: mockRect({ bottom: height + 1 }) }) + + domEvent.scroll(window) } describe('Sticky', () => { @@ -49,194 +75,233 @@ describe('Sticky', () => { let requestAnimationFrame before(() => { - window.requestAnimationFrame = fn => fn() requestAnimationFrame = window.requestAnimationFrame + window.requestAnimationFrame = fn => fn() }) after(() => { window.requestAnimationFrame = requestAnimationFrame }) - it('should use window as default scroll context', () => { - const onStick = sandbox.spy() - const wrapper = mount() - const instance = wrapper.instance() - instance.triggerRef = { getBoundingClientRect: () => ({ top: -1 }) } - window.dispatchEvent(new Event('scroll')) - onStick.should.have.been.called() + beforeEach(() => { + wrapper = undefined }) - it('should set a scroll context', () => { - const div = document.createElement('div') - const onStick = sandbox.spy() - const wrapper = mount() - const instance = wrapper.instance() - instance.triggerRef = { getBoundingClientRect: () => ({ top: -1 }) } - window.dispatchEvent(new Event('scroll')) - onStick.should.not.have.been.called() - div.dispatchEvent(new Event('scroll')) - onStick.should.have.been.called() + afterEach(() => { + if (wrapper && wrapper.unmount) wrapper.unmount() }) - it('should create two divs', () => { - const children = shallow().children() + describe('children', () => { + it('should create two divs', () => { + const children = shallow().children() - children.should.have.length(2) - children.everyWhere(child => child.should.have.tagName('div')) + children.should.have.length(2) + children.everyWhere(child => child.should.have.tagName('div')) + }) }) - it('should stick to top of screen', () => { - const offset = 12 - const bottomOffset = 12 - const height = 200 - const contextEl = { getBoundingClientRect: () => ({}) } - const wrapper = mount( - , - ) - - // Scroll after trigger - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - wrapper.childAt(1).props().style.should.have.property('position', 'fixed') - wrapper.childAt(1).props().style.should.have.property('top', offset) - }) + describe('active', () => { + it('should handle update on mount when active', () => { + const onTop = sandbox.spy() + mount() - it('should stick to bottom of context', () => { - const offset = 20 - const bottomOffset = 10 - const height = 100 - const contextEl = { getBoundingClientRect: () => ({}) } - const wrapper = mount( - , - ) + onTop.should.have.been.calledOnce() + }) - scrollAfterContext(wrapper, contextEl, { bottomOffset, offset, height }) + it('should not handle update on mount when not active', () => { + const onTop = sandbox.spy() + mount() - wrapper.childAt(1).props().style.should.have.property('position', 'fixed') - wrapper.childAt(1).props().style.should.have.property('top', -1 - height) - }) + onTop.should.have.not.been.called() + }) + + it('fires event when changes to true', () => { + const onTop = sandbox.spy() + wrapperMount() + onTop.should.have.not.been.called() + + wrapper.setProps({ active: true }) + onTop.should.have.been.calledOnce() + }) - it('should push component back', () => { - const offset = 10 - const bottomOffset = 30 - const height = 100 - const contextEl = { getBoundingClientRect: () => ({}) } - const wrapper = mount( - , - ) - - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - - // Scroll back: component should still stick to context bottom - scrollToContextBottom(wrapper, contextEl, { offset, height }) - contextEl.getBoundingClientRect = () => ({ bottom: 0 }) - window.dispatchEvent(new Event('scroll')) - wrapper.childAt(1).props().style.should.have.property('position', 'fixed') - wrapper.childAt(1).props().style.should.have.property('top', -100) - - // Scroll a bit before the top: component should stick to screen bottom - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - wrapper.childAt(1).props().style.should.have.property('position', 'fixed') - wrapper.childAt(1).props().style.should.have.property('top', null) - wrapper.childAt(1).props().style.should.have.property('bottom', bottomOffset) + it('omits event when changes to false', () => { + const onTop = sandbox.spy() + mockPositions() + wrapperMount() + onTop.should.have.been.calledOnce() + + wrapper.setProps({ active: false }) + scrollToTop() + onTop.should.have.been.calledOnce() + }) }) - it('should stop pushing when reaching top', () => { - const offset = 10 - const bottomOffset = 10 - const height = 100 - const contextEl = { getBoundingClientRect: () => ({}) } - const wrapper = mount( - , - ) - - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - scrollToContextBottom(wrapper, contextEl, { offset, height }) - scrollToTop(wrapper, contextEl, { height, bottomOffset, offset }) - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - // Component should stick again to the top - wrapper.childAt(1).props().style.should.have.property('position', 'fixed') - wrapper.childAt(1).props().style.should.have.property('top', offset) + describe('behaviour', () => { + it('should stick to top of screen', () => { + const offset = 12 + mockContextEl() + mockPositions({ offset, bottomOffset: 12, height: 200 }) + wrapperMount() + + // Scroll after trigger + scrollAfterTrigger() + wrapper.childAt(1).props().style.should.have.property('position', 'fixed') + wrapper.childAt(1).props().style.should.have.property('top', offset) + }) + + it('should stick to bottom of context', () => { + const height = 100 + mockContextEl() + mockPositions({ height, bottomOffset: 10, offset: 20 }) + wrapperMount() + + scrollAfterContext() + wrapper.childAt(1).props().style.should.have.property('position', 'fixed') + wrapper.childAt(1).props().style.should.have.property('top', -1 - height) + }) + }) + describe('onBottom', () => { + it('is called with (e, data) when is on bottom', () => { + const onBottom = sandbox.spy() + mockContextEl() + mockPositions() + wrapperMount() + + scrollAfterContext() + onBottom.should.have.been.calledOnce() + onBottom.should.have.been.calledWithMatch({}, positions) + onBottom.reset() + + scrollToTop() + onBottom.should.not.have.been.called() + }) }) - it('should return true if oversized', () => { - const offset = 20 - const bottomOffset = 15 - const height = 100000 - const contextEl = { getBoundingClientRect: () => ({}) } - const wrapper = mount( - , - ) - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - wrapper.instance().isOversized().should.be.equal(true) + describe('onStick', () => { + it('is called with (e, data) when stick', () => { + const onStick = sandbox.spy() + mockContextEl() + mockPositions({ bottomOffset: 10, height: 50 }) + wrapperMount() + + scrollAfterTrigger() + onStick.should.have.been.calledTwice() + onStick.should.have.been.calledWithMatch({}, positions) + onStick.reset() + + scrollToTop() + onStick.should.not.have.been.called() + }) }) - it('should fire onStick callback', () => { - const offset = 5 - const bottomOffset = 10 - const height = 50 - const contextEl = { getBoundingClientRect: () => ({}) } - const onStick = sandbox.spy() - const wrapper = mount( - , - ) - - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - onStick.should.have.been.calledWithMatch({}, { bottomOffset, offset }) - onStick.reset() - - scrollToTop(wrapper, contextEl, { bottomOffset, offset, height }) - onStick.should.not.have.been.called() + describe('onTop', () => { + it('is called with (e, data) when is on top', () => { + const onTop = sandbox.spy() + mockContextEl() + mockPositions({ bottomOffset: 10, height: 50 }) + wrapperMount() + + scrollAfterContext() + onTop.should.not.have.been.called() + + scrollToTop() + onTop.should.have.been.calledOnce() + onTop.should.have.been.calledWithMatch({}, positions) + }) }) - it('should fire onUnstick callback', () => { - const offset = 5 - const bottomOffset = 10 - const height = 50 - const contextEl = { getBoundingClientRect: () => ({}) } - const onUnstick = sandbox.spy() - const wrapper = mount( - , - ) - - scrollAfterTrigger(wrapper, contextEl, { bottomOffset, offset, height }) - onUnstick.should.not.have.been.called() - - scrollToTop(wrapper, contextEl, { bottomOffset, offset, height }) - onUnstick.should.have.been.calledWithMatch({}, { bottomOffset, offset }) + describe('onUnstick', () => { + it('is called with (e, data) when unstick', () => { + const onUnstick = sandbox.spy() + mockContextEl() + mockPositions({ bottomOffset: 10, height: 50 }) + wrapperMount() + + scrollAfterTrigger() + onUnstick.should.not.have.been.called() + + scrollToTop() + onUnstick.should.have.been.calledOnce() + onUnstick.should.have.been.calledWithMatch({}, positions) + }) }) - it('should fire onTop callback', () => { - const offset = 5 - const bottomOffset = 10 - const height = 50 - const contextEl = { getBoundingClientRect: () => ({}) } - const onTop = sandbox.spy() - const wrapper = mount( - , - ) - - scrollAfterContext(wrapper, contextEl, { bottomOffset, offset, height }) - onTop.should.not.have.been.called() - - scrollToTop(wrapper, contextEl, { bottomOffset, offset, height }) - onTop.should.have.been.calledWithMatch({}, { bottomOffset, offset }) + describe('pushing', () => { + it('should push component back', () => { + const bottomOffset = 30 + mockContextEl() + mockPositions({ bottomOffset, height: 100, offset: 10 }) + wrapperMount() + + scrollAfterTrigger() + + // Scroll back: component should still stick to context bottom + scrollToContextBottom() + wrapper.setProps({ context: mockContextEl({ bottom: 0 }) }) + domEvent.scroll(window) + + wrapper.childAt(1).props().style.should.have.property('position', 'fixed') + wrapper.childAt(1).props().style.should.have.property('top', -100) + + // Scroll a bit before the top: component should stick to screen bottom + scrollAfterTrigger() + + wrapper.childAt(1).props().style.should.have.property('position', 'fixed') + wrapper.childAt(1).props().style.should.have.property('top', null) + wrapper.childAt(1).props().style.should.have.property('bottom', bottomOffset) + }) + + it('should stop pushing when reaching top', () => { + const offset = 10 + mockContextEl() + mockPositions({ offset, bottomOffset: 10, height: 100 }) + wrapperMount() + + scrollAfterTrigger() + scrollToContextBottom() + scrollToTop() + scrollAfterTrigger() + + // Component should stick again to the top + const childStyle = wrapper.childAt(1).props().style + + childStyle.should.have.property('position', 'fixed') + childStyle.should.have.property('top', offset) + }) + + it('should return true if oversized', () => { + mockContextEl() + mockPositions({ bottomOffset: 15, height: 100000, offset: 20 }) + wrapperMount() + + scrollAfterTrigger() + wrapper.instance().isOversized().should.be.equal(true) + }) }) - it('should fire onBottom callback', () => { - const offset = 5 - const bottomOffset = 5 - const height = 5 - const contextEl = { getBoundingClientRect: () => ({}) } - const onBottom = sandbox.spy() - const wrapper = mount( - , - ) - - scrollAfterContext(wrapper, contextEl, { bottomOffset, offset, height }) - onBottom.should.have.been.calledWithMatch({}, { bottomOffset, offset }) - onBottom.reset() - - scrollToTop(wrapper, contextEl, { bottomOffset, offset, height }) - onBottom.should.not.have.been.called() + describe('scrollContext', () => { + it('should use window as default', () => { + const onStick = sandbox.spy() + const instance = mount().instance() + + instance.triggerRef = mockRect({ top: -1 }) + domEvent.scroll(window) + + onStick.should.have.been.called() + }) + + it('should set a scroll context', () => { + const div = document.createElement('div') + const onStick = sandbox.spy() + const instance = mount().instance() + + instance.triggerRef = mockRect({ top: -1 }) + + domEvent.scroll(window) + onStick.should.not.have.been.called() + + domEvent.scroll(div) + onStick.should.have.been.called() + }) }) }) diff --git a/test/utils/domEvent.js b/test/utils/domEvent.js index b4d0211ada..d12dacff54 100644 --- a/test/utils/domEvent.js +++ b/test/utils/domEvent.js @@ -18,6 +18,14 @@ export const fire = (node, eventType, data = {}) => { return simulant.fire(DOMNode, event) } +/** + * Dispatch a 'click' event on a DOM node. + * @param {String|Object} node A querySelector string or DOM node. + * @param {Object} [data] Additional event data. + * @returns {Object} The event + */ +export const click = (node, data) => fire(node, 'click', data) + /** * Dispatch a 'keydown' event on a DOM node. * @param {String|Object} node A querySelector string or DOM node. @@ -59,19 +67,20 @@ export const mouseOver = (node, data) => fire(node, 'mouseover', data) export const mouseUp = (node, data) => fire(node, 'mouseup', data) /** - * Dispatch a 'click' event on a DOM node. + * Dispatch a 'scroll' event on a DOM node. * @param {String|Object} node A querySelector string or DOM node. * @param {Object} [data] Additional event data. * @returns {Object} The event */ -export const click = (node, data) => fire(node, 'click', data) +export const scroll = (node, data) => fire(node, 'scroll', data) export default { fire, + click, + keyDown, mouseEnter, mouseLeave, mouseOver, mouseUp, - keyDown, - click, + scroll, }