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()
- })
- })
- })
})