diff --git a/src/modules/Sticky/Sticky.js b/src/modules/Sticky/Sticky.js index d8b46c0bc5..49ec2aaa67 100644 --- a/src/modules/Sticky/Sticky.js +++ b/src/modules/Sticky/Sticky.js @@ -2,266 +2,273 @@ import { isRefObject } from '@fluentui/react-component-ref' import cx from 'clsx' import _ from 'lodash' import PropTypes from 'prop-types' -import React, { Component, createRef } from 'react' +import React from 'react' import { customPropTypes, - eventStack, getElementType, getUnhandledProps, isBrowser, + useEventCallback, + useIsomorphicLayoutEffect, } from '../../lib' /** * Sticky content stays fixed to the browser viewport while another column of content is visible on the page. */ -export default class Sticky extends Component { - state = { - active: true, - sticky: false, - } +const Sticky = React.forwardRef(function (props, ref) { + const { + active, + bottomOffset, + children, + className, + context, + offset, + scrollContext, + styleElement, + } = props + + const [sticky, setSticky] = React.useState(false) + const [bound, setBound] = React.useState() + const [bottom, setBottom] = React.useState() + const [pushing, setPushing] = React.useState() + const [top, setTop] = React.useState() + + const stickyRef = React.useRef() + const triggerRef = React.useRef() + + const triggerRect = React.useRef() + const contextRect = React.useRef() + const stickyRect = React.useRef() + + const frameId = React.useRef() + const ticking = React.useRef() - stickyRef = createRef() - triggerRef = createRef() + // ---------------------------------------- + // Helpers + // ---------------------------------------- - componentDidMount() { - if (!isBrowser()) return - const { active } = this.state + const assignRects = () => { + const contextNode = isRefObject(context) ? context.current : context || document.body - if (active) { - this.handleUpdate() - this.addListeners(this.props.scrollContext) - } + triggerRect.current = triggerRef.current.getBoundingClientRect() + contextRect.current = contextNode.getBoundingClientRect() + stickyRect.current = stickyRef.current.getBoundingClientRect() } - static getDerivedStateFromProps(props, state) { - if (state.active !== props.active && !props.active) { - return { active: props.active, sticky: false } + const computeStyle = () => { + if (!sticky) { + return styleElement } - return { active: props.active } + return { + bottom: bound ? 0 : bottom, + top: bound ? undefined : top, + width: triggerRect.current.width, + ...styleElement, + } } - componentDidUpdate(prevProps, prevState) { - if (prevState.active === this.state.active) { - if (prevProps.scrollContext !== this.props.scrollContext) { - this.removeListeners(prevProps.scrollContext) - this.addListeners(this.props.scrollContext) - } - - return - } + // Return true when the component reached the bottom of the context + const didReachContextBottom = () => + stickyRect.current.height + offset >= contextRect.current.bottom - if (this.state.active) { - this.handleUpdate() - this.addListeners(this.props.scrollContext) - return - } + // Return true when the component reached the starting point + const didReachStartingPoint = () => stickyRect.current.top <= triggerRect.current.top - this.removeListeners(prevProps.scrollContext) - } + // Return true when the top of the screen overpasses the Sticky component + const didTouchScreenTop = () => triggerRect.current.top < offset - componentWillUnmount() { - if (!isBrowser()) return - const { active } = this.state + // Return true when the bottom of the screen overpasses the Sticky component + const didTouchScreenBottom = () => contextRect.current.bottom + bottomOffset > window.innerHeight - if (active) { - this.removeListeners(this.props.scrollContext) - cancelAnimationFrame(this.frameId) - } - } + // Return true if the height of the component is higher than the window + const isOversized = () => stickyRect.current.height > window.innerHeight // ---------------------------------------- - // Events + // Stick helpers // ---------------------------------------- - addListeners = (scrollContext) => { - const scrollContextNode = isRefObject(scrollContext) ? scrollContext.current : scrollContext - - if (scrollContextNode) { - eventStack.sub('resize', this.handleUpdate, { target: scrollContextNode }) - eventStack.sub('scroll', this.handleUpdate, { target: scrollContextNode }) + // If true, the component will stick to the bottom of the screen instead of the top + const togglePushing = (value) => { + if (props.pushing) { + setPushing(value) } } - removeListeners = (scrollContext) => { - const scrollContextNode = isRefObject(scrollContext) ? scrollContext.current : scrollContext + const setSticked = (e, newBound) => { + setBound(newBound) + setSticky(true) - if (scrollContextNode) { - eventStack.unsub('resize', this.handleUpdate, { target: scrollContextNode }) - eventStack.unsub('scroll', this.handleUpdate, { target: scrollContextNode }) - } + _.invoke(props, 'onStick', e, props) } - // ---------------------------------------- - // Handlers - // ---------------------------------------- + const setUnsticked = (e, newBound) => { + setBound(newBound) + setSticky(false) - update = (e) => { - const { pushing } = this.state + _.invoke(props, 'onUnstick', e, props) + } - this.ticking = false - this.assignRects() + const stickToContextBottom = (e) => { + setSticked(e, true) + togglePushing(true) - if (pushing) { - if (this.didReachStartingPoint()) return this.stickToContextTop(e) - if (this.didTouchScreenBottom()) return this.stickToScreenBottom(e) - return this.stickToContextBottom(e) - } + _.invoke(props, 'onBottom', e, props) + } - if (this.isOversized()) { - if (this.contextRect.top > 0) return this.stickToContextTop(e) - if (this.contextRect.bottom < window.innerHeight) return this.stickToContextBottom(e) - } + const stickToContextTop = (e) => { + setUnsticked(e, false) + togglePushing(false) - if (this.didTouchScreenTop()) { - if (this.didReachContextBottom()) return this.stickToContextBottom(e) - return this.stickToScreenTop(e) - } + _.invoke(props, 'onTop', e, props) + } + + const stickToScreenBottom = (e) => { + setSticked(e, false) - return this.stickToContextTop(e) + setBottom(bottomOffset) + setTop(null) } - handleUpdate = (e) => { - if (!this.ticking) { - this.ticking = true - this.frameId = requestAnimationFrame(() => this.update(e)) - } + const stickToScreenTop = (e) => { + setSticked(e, false) + + setBottom(null) + setTop(offset) } // ---------------------------------------- - // Helpers + // Handlers // ---------------------------------------- - assignRects = () => { - const { context } = this.props - const contextNode = isRefObject(context) ? context.current : context || document.body + const update = (e) => { + ticking.current = false + assignRects() - this.triggerRect = this.triggerRef.current.getBoundingClientRect() - this.contextRect = contextNode.getBoundingClientRect() - this.stickyRect = this.stickyRef.current.getBoundingClientRect() - } + if (pushing) { + if (didReachStartingPoint()) { + stickToContextTop(e) + return + } - computeStyle() { - const { styleElement } = this.props - const { bottom, bound, sticky, top } = this.state + if (didTouchScreenBottom()) { + stickToScreenBottom(e) + return + } - if (!sticky) return styleElement - return { - bottom: bound ? 0 : bottom, - top: bound ? undefined : top, - width: this.triggerRect.width, - ...styleElement, + stickToContextBottom(e) + return } - } - - // Return true when the component reached the bottom of the context - didReachContextBottom = () => { - const { offset } = this.props - return this.stickyRect.height + offset >= this.contextRect.bottom - } + if (isOversized()) { + if (contextRect.current.top > 0) { + stickToContextTop(e) + return + } - // Return true when the component reached the starting point - didReachStartingPoint = () => this.stickyRect.top <= this.triggerRect.top + if (contextRect.current.bottom < window.innerHeight) { + stickToContextBottom(e) + return + } + } - // Return true when the top of the screen overpasses the Sticky component - didTouchScreenTop = () => this.triggerRect.top < this.props.offset + if (didTouchScreenTop()) { + if (didReachContextBottom()) { + stickToContextBottom(e) + return + } - // Return true when the bottom of the screen overpasses the Sticky component - didTouchScreenBottom = () => { - const { bottomOffset } = this.props + stickToScreenTop(e) + return + } - return this.contextRect.bottom + bottomOffset > window.innerHeight + stickToContextTop(e) } - // Return true if the height of the component is higher than the window - isOversized = () => this.stickyRect.height > window.innerHeight + const handleUpdate = useEventCallback((e) => { + if (!ticking.current) { + ticking.current = true + frameId.current = requestAnimationFrame(() => update(e)) + } + }) // ---------------------------------------- - // Stick helpers + // State control // ---------------------------------------- - // If true, the component will stick to the bottom of the screen instead of the top - pushing = (pushing) => { - const { pushing: possible } = this.props - - if (possible) this.setState({ pushing }) - } - - stick = (e, bound) => { - this.setState({ bound, sticky: true }) - _.invoke(this.props, 'onStick', e, this.props) - } - - unstick = (e, bound) => { - this.setState({ bound, sticky: false }) - _.invoke(this.props, 'onUnstick', e, this.props) - } - - stickToContextBottom = (e) => { - _.invoke(this.props, 'onBottom', e, this.props) + useIsomorphicLayoutEffect(() => { + if (!active) { + setSticky(false) + } + }, [active]) - this.stick(e, true) - this.pushing(true) - } + // ---------------------------------------- + // Effects + // ---------------------------------------- - stickToContextTop = (e) => { - _.invoke(this.props, 'onTop', e, this.props) + useIsomorphicLayoutEffect(() => { + if (active) { + handleUpdate() + } + }, [active]) - this.unstick(e, false) - this.pushing(false) - } + React.useEffect(() => { + return () => { + cancelAnimationFrame(frameId.current) + } + }, []) - stickToScreenBottom = (e) => { - const { bottomOffset: bottom } = this.props + // ---------------------------------------- + // Document events + // ---------------------------------------- - this.stick(e, false) - this.setState({ bottom, top: null }) - } + React.useEffect(() => { + const scrollContextNode = isRefObject(scrollContext) ? scrollContext.current : scrollContext - stickToScreenTop = (e) => { - const { offset: top } = this.props + if (active && scrollContextNode) { + scrollContextNode?.addEventListener('resize', handleUpdate) + scrollContextNode?.addEventListener('scroll', handleUpdate) + } - this.stick(e, false) - this.setState({ top, bottom: null }) - } + return () => { + scrollContextNode?.removeEventListener('resize', handleUpdate) + scrollContextNode?.removeEventListener('scroll', handleUpdate) + } + }, [active, scrollContext]) // ---------------------------------------- // Render // ---------------------------------------- - render() { - const { children, className } = this.props - const { bottom, bound, sticky } = this.state - const rest = getUnhandledProps(Sticky, this.props) - const ElementType = getElementType(Sticky, this.props) - - const containerClasses = cx( - sticky && 'ui', - sticky && 'stuck-container', - sticky && (bound ? 'bound-container' : 'fixed-container'), - className, - ) - const elementClasses = cx( - 'ui', - sticky && (bound ? 'bound bottom' : 'fixed'), - sticky && !bound && (bottom === null ? 'top' : 'bottom'), - 'sticky', - ) - const triggerStyles = sticky && this.stickyRect ? { height: this.stickyRect.height } : {} - - return ( - -
-
- {children} -
- - ) - } -} - + const rest = getUnhandledProps(Sticky, props) + const ElementType = getElementType(Sticky, props) + + const containerClasses = cx( + sticky && 'ui', + sticky && 'stuck-container', + sticky && (bound ? 'bound-container' : 'fixed-container'), + className, + ) + const elementClasses = cx( + 'ui', + sticky && (bound ? 'bound bottom' : 'fixed'), + sticky && !bound && (bottom === null ? 'top' : 'bottom'), + 'sticky', + ) + const triggerStyles = sticky ? { height: stickyRect.current?.height } : {} + + return ( + +
+
+ {children} +
+ + ) +}) + +Sticky.displayName = 'Sticky' Sticky.propTypes = { /** An element type to render as (string or function). */ as: PropTypes.elementType, @@ -332,3 +339,5 @@ Sticky.defaultProps = { offset: 0, scrollContext: isBrowser() ? window : null, } + +export default Sticky diff --git a/test/specs/modules/Sticky/Sticky-test.js b/test/specs/modules/Sticky/Sticky-test.js index 4c17134209..6e3b44c429 100644 --- a/test/specs/modules/Sticky/Sticky-test.js +++ b/test/specs/modules/Sticky/Sticky-test.js @@ -9,9 +9,31 @@ let contextEl let wrapper let positions -const mockRect = (values = {}) => ({ getBoundingClientRect: () => values }) +const mockContextEl = (values = {}) => (contextEl = { getBoundingClientRect: () => values }) + +const mockTriggerEl = (values = {}) => { + const wrapperEl = wrapper.getDOMNode() + const triggerEl = wrapperEl.childNodes[0] + + // try to remove any existing spy in case it exists + try { + triggerEl.getBoundingClientRect.restore() + // eslint-disable-next-line no-empty + } catch (e) {} + sandbox.stub(triggerEl, 'getBoundingClientRect').callsFake(() => values) +} + +const mockStickyEl = (values = {}) => { + const wrapperEl = wrapper.getDOMNode() + const stickyEl = wrapperEl.childNodes[1] -const mockContextEl = (values = {}) => (contextEl = mockRect(values)) + // try to remove any existing spy in case it exists + try { + stickyEl.getBoundingClientRect.restore() + // eslint-disable-next-line no-empty + } catch (e) {} + sandbox.stub(stickyEl, 'getBoundingClientRect').callsFake(() => values) +} const mockPositions = ({ bottomOffset = 5, offset = 5, height = 5 } = {}) => (positions = { @@ -25,11 +47,13 @@ const wrapperMount = (...args) => (wrapper = mount(...args)) // Scroll to the top of the screen const scrollToTop = () => { const { bottomOffset, height, offset } = positions - const instance = wrapper.instance() - wrapper.setProps({ context: mockRect({ bottom: height + offset + bottomOffset }) }) - instance.triggerRef = { current: mockRect({ top: offset }) } - instance.stickyRef = { current: mockRect({ height, top: offset }) } + wrapper.setProps({ + context: { getBoundingClientRect: () => ({ bottom: height + offset + bottomOffset }) }, + }) + + mockTriggerEl({ top: offset }) + mockStickyEl({ height, top: offset }) domEvent.scroll(window) } @@ -37,11 +61,13 @@ const scrollToTop = () => { // Scroll until the trigger is not visible const scrollAfterTrigger = () => { const { bottomOffset, height, offset } = positions - const instance = wrapper.instance() - wrapper.setProps({ context: mockRect({ bottom: window.innerHeight - bottomOffset + 1 }) }) - instance.triggerRef = { current: mockRect({ top: offset - 1 }) } - instance.stickyRef = { current: mockRect({ height }) } + wrapper.setProps({ + context: { getBoundingClientRect: () => ({ bottom: window.innerHeight - bottomOffset + 1 }) }, + }) + + mockTriggerEl({ top: offset - 1 }) + mockStickyEl({ height }) domEvent.scroll(window) } @@ -49,11 +75,11 @@ const scrollAfterTrigger = () => { // Scroll until the context bottom is not visible const scrollAfterContext = () => { const { height, offset } = positions - const instance = wrapper.instance() - wrapper.setProps({ context: mockRect({ bottom: -1 }) }) - instance.triggerRef = { current: mockRect({ top: offset - 1 }) } - instance.stickyRef = { current: mockRect({ height }) } + wrapper.setProps({ context: { getBoundingClientRect: () => ({ bottom: -1 }) } }) + + mockTriggerEl({ top: offset - 1 }) + mockStickyEl({ height }) domEvent.scroll(window) } @@ -61,17 +87,18 @@ const scrollAfterContext = () => { // Scroll to the last part of the context const scrollToContextBottom = () => { const { height, offset } = positions - const instance = wrapper.instance() - instance.triggerRef = { current: mockRect({ top: offset - 1 }) } - instance.stickyRef = { current: mockRect({ height }) } - wrapper.setProps({ context: mockRect({ bottom: height + 1 }) }) + wrapper.setProps({ context: { getBoundingClientRect: () => ({ bottom: height + 1 }) } }) + + mockTriggerEl({ top: offset - 1 }) + mockStickyEl({ height }) domEvent.scroll(window) } describe('Sticky', () => { common.isConformant(Sticky) + common.forwardsRef(Sticky, { requiredProps: { active: false } }) common.rendersChildren(Sticky, { rendersContent: false, }) @@ -109,13 +136,14 @@ describe('Sticky', () => { it('should not handle update on mount when not active', () => { const onTop = sandbox.spy() - mount() + wrapperMount() onTop.should.have.not.been.called() }) it('fires event when changes to true', () => { const onTop = sandbox.spy() + wrapperMount() onTop.should.have.not.been.called() @@ -126,8 +154,10 @@ describe('Sticky', () => { it('omits event and removes styles when changes to false', () => { const onStick = sandbox.spy() const onUnStick = sandbox.spy() + mockContextEl() mockPositions({ bottomOffset: 10, height: 50 }) + wrapperMount( , ) @@ -135,6 +165,7 @@ describe('Sticky', () => { _.forEach(['ui', 'sticky', 'fixed', 'top'], (className) => wrapper.childAt(0).childAt(1).should.have.className(className), ) + onStick.should.have.been.calledOnce() onStick.should.have.been.calledWithMatch(undefined, positions) @@ -159,6 +190,7 @@ describe('Sticky', () => { it('should stick to top of screen', () => { mockContextEl() mockPositions({ bottomOffset: 12, height: 200, offset: 12 }) + wrapperMount() // Scroll after trigger @@ -167,6 +199,7 @@ describe('Sticky', () => { _.forEach(['ui', 'sticky', 'fixed', 'top'], (className) => wrapper.childAt(0).childAt(1).should.have.className(className), ) + wrapper.childAt(0).childAt(1).should.have.style('top', '12px') }) @@ -281,15 +314,18 @@ describe('Sticky', () => { scrollAfterTrigger() wrapper.childAt(0).childAt(1).should.have.style('bottom', '30px') + _.forEach(['ui', 'sticky', 'fixed', 'bottom'], (className) => wrapper.childAt(0).childAt(1).should.have.className(className), ) + wrapper.childAt(0).childAt(1).should.not.have.style('top') }) it('should stop pushing when reaching top', () => { mockContextEl() mockPositions({ bottomOffset: 10, height: 100, offset: 10 }) + wrapperMount() scrollAfterTrigger() @@ -301,36 +337,28 @@ describe('Sticky', () => { _.forEach(['ui', 'sticky', 'fixed', 'top'], (className) => wrapper.childAt(0).childAt(1).should.have.className(className), ) - wrapper.childAt(0).childAt(1).should.have.style('top', '10px') - }) - it('should return true if oversized', () => { - mockContextEl() - mockPositions({ bottomOffset: 15, height: 100000, offset: 20 }) - wrapperMount() - - scrollAfterTrigger() - wrapper.instance().isOversized().should.be.equal(true) + wrapper.childAt(0).childAt(1).should.have.style('top', '10px') }) }) describe('scrollContext', () => { it('should use window as default', () => { const onStick = sandbox.spy() - const instance = mount().instance() - instance.triggerRef = { current: mockRect({ top: -1 }) } - domEvent.scroll(window) + wrapperMount() + mockTriggerEl({ 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 = { current: mockRect({ top: -1 }) } + wrapperMount() + mockTriggerEl({ top: -1 }) domEvent.scroll(window) onStick.should.not.have.been.called() @@ -342,11 +370,9 @@ describe('Sticky', () => { it('should set a scroll context via React refs', () => { const scrollContextRef = { current: document.createElement('div') } const onStick = sandbox.spy() - const instance = mount( - , - ).instance() - instance.triggerRef = { current: mockRect({ top: -1 }) } + wrapperMount() + mockTriggerEl({ top: -1 }) domEvent.scroll(window) onStick.should.not.have.been.called() @@ -357,9 +383,9 @@ describe('Sticky', () => { it('should not call onStick when context is null', () => { const onStick = sandbox.spy() - wrapperMount() - wrapper.instance().triggerRef = { current: mockRect({ top: -1 }) } + wrapperMount() + mockTriggerEl({ top: -1 }) domEvent.scroll(document) onStick.should.not.have.been.called() @@ -371,28 +397,11 @@ describe('Sticky', () => { wrapperMount() wrapper.setProps({ scrollContext: div }) - wrapper.instance().triggerRef = { current: mockRect({ top: -1 }) } + mockTriggerEl({ top: -1 }) domEvent.scroll(div) onStick.should.have.been.called() }) - - it('should not call onStick when scrollContext changes and component is unmounted', () => { - const div = document.createElement('div') - const onStick = sandbox.spy() - const renderedComponent = mount() - const instance = renderedComponent.instance() - - instance.triggerRef = { current: mockRect({ top: -1 }) } - renderedComponent.setProps({ scrollContext: div }) - renderedComponent.unmount() - - domEvent.scroll(div) - onStick.should.not.have.been.called() - - domEvent.scroll(document) - onStick.should.not.have.been.called() - }) }) describe('styleElement', () => { @@ -403,38 +412,4 @@ describe('Sticky', () => { element.should.have.style('z-index', '10') }) }) - - describe('update', () => { - it('is called on scroll', () => { - const instance = mount().instance() - const update = sandbox.spy(instance, 'update') - - domEvent.scroll(window) - update.should.have.been.calledOnce() - }) - - it('is called on resize', () => { - const instance = mount().instance() - const update = sandbox.spy(instance, 'update') - - domEvent.resize(window) - update.should.have.been.calledOnce() - }) - - it('is not called after unmount', (done) => { - window.requestAnimationFrame.restore() - sandbox.stub(window, 'requestAnimationFrame').callsFake((fn) => setTimeout(fn, 0)) - sandbox.stub(window, 'cancelAnimationFrame').callsFake((id) => clearTimeout(id)) - - const instance = wrapperMount().instance() - const update = sandbox.spy(instance, 'update') - - domEvent.resize(window) - wrapper.unmount() - window.requestAnimationFrame(() => { - update.should.not.have.been.called() - done() - }) - }) - }) })