semantic-ui-react
Version:
The official Semantic-UI-React integration.
427 lines (349 loc) • 11.5 kB
JavaScript
import { Ref } from '@fluentui/react-component-ref'
import _ from 'lodash'
import PropTypes from 'prop-types'
import React, { Component, createRef } from 'react'
import {
eventStack,
getElementType,
getUnhandledProps,
normalizeOffset,
isBrowser,
} from '../../lib'
/**
* Visibility provides a set of callbacks for when a content appears in the viewport.
*/
export default class Visibility extends Component {
calculations = {
bottomPassed: false,
bottomVisible: false,
fits: false,
passing: false,
offScreen: false,
onScreen: false,
topPassed: false,
topVisible: false,
}
firedCallbacks = []
ref = createRef()
// ----------------------------------------
// Lifecycle
// ----------------------------------------
componentDidMount() {
this.mounted = true
if (!isBrowser()) return
const { context, fireOnMount, updateOn } = this.props
this.pageYOffset = this.getPageYOffset()
this.attachHandlers(context, updateOn)
if (fireOnMount) this.update()
}
componentDidUpdate(prevProps) {
const cleanHappened =
prevProps.continuous !== this.props.continuous ||
prevProps.once !== this.props.once ||
prevProps.updateOn !== this.props.updateOn
// Heads up! We should clean up array of happened callbacks, if values of these props are changed
if (cleanHappened) this.firedCallbacks = []
if (prevProps.context !== this.props.context || prevProps.updateOn !== this.props.updateOn) {
this.unattachHandlers(prevProps.context)
this.attachHandlers(this.props.context, this.props.updateOn)
}
}
componentWillUnmount() {
const { context } = this.props
this.unattachHandlers(context)
this.mounted = false
}
attachHandlers(context, updateOn) {
if (updateOn === 'events') {
if (context) {
eventStack.sub('resize', this.handleUpdate, { target: context })
eventStack.sub('scroll', this.handleUpdate, { target: context })
}
return
}
// Heads up!
// We will deal with `repaint` there
this.handleUpdate()
}
unattachHandlers(context) {
if (context) {
eventStack.unsub('resize', this.handleUpdate, { target: context })
eventStack.unsub('scroll', this.handleUpdate, { target: context })
}
if (this.frameId) cancelAnimationFrame(this.frameId)
}
// ----------------------------------------
// Callback handling
// ----------------------------------------
execute(callback, name) {
const { continuous } = this.props
if (!callback) return
// Heads up! When `continuous` is true, callback will be fired always
if (!continuous && _.includes(this.firedCallbacks, name)) return
callback(null, { ...this.props, calculations: this.calculations })
this.firedCallbacks.push(name)
}
fire = ({ callback, name }, value, reverse = false) => {
const { continuous, once } = this.props
// Heads up! For the execution is required:
// - current value correspond to the fired direction
// - `continuous` is true or calculation values are different
const matchesDirection = this.calculations[value] !== reverse
const executionPossible = continuous || this.calculations[value] !== this.oldCalculations[value]
if (matchesDirection && executionPossible) this.execute(callback, name)
// Heads up! We should remove callback from the happened when it's not `once`
if (!once) this.firedCallbacks = _.without(this.firedCallbacks, name)
}
fireOnPassed() {
const { percentagePassed, pixelsPassed } = this.calculations
const { onPassed } = this.props
_.forEach(onPassed, (callback, passed) => {
const pixelsValue = Number(passed)
if (pixelsValue && pixelsPassed >= pixelsValue) {
this.execute(callback, passed)
return
}
const matchPercentage = `${passed}`.match(/^(\d+)%$/)
if (!matchPercentage) return
const percentageValue = Number(matchPercentage[1]) / 100
if (percentagePassed >= percentageValue) this.execute(callback, passed)
})
}
handleUpdate = () => {
if (this.ticking) return
this.ticking = true
this.frameId = requestAnimationFrame(this.update)
}
update = () => {
if (!this.mounted) return
this.ticking = false
this.oldCalculations = this.calculations
this.calculations = this.computeCalculations()
this.pageYOffset = this.getPageYOffset()
const {
onBottomPassed,
onBottomPassedReverse,
onBottomVisible,
onBottomVisibleReverse,
onPassing,
onPassingReverse,
onTopPassed,
onTopPassedReverse,
onTopVisible,
onTopVisibleReverse,
onOffScreen,
onOnScreen,
updateOn,
} = this.props
const forward = {
bottomPassed: { callback: onBottomPassed, name: 'onBottomPassed' },
bottomVisible: { callback: onBottomVisible, name: 'onBottomVisible' },
passing: { callback: onPassing, name: 'onPassing' },
offScreen: { callback: onOffScreen, name: 'onOffScreen' },
onScreen: { callback: onOnScreen, name: 'onOnScreen' },
topPassed: { callback: onTopPassed, name: 'onTopPassed' },
topVisible: { callback: onTopVisible, name: 'onTopVisible' },
}
const reverse = {
bottomPassed: { callback: onBottomPassedReverse, name: 'onBottomPassedReverse' },
bottomVisible: { callback: onBottomVisibleReverse, name: 'onBottomVisibleReverse' },
passing: { callback: onPassingReverse, name: 'onPassingReverse' },
topPassed: { callback: onTopPassedReverse, name: 'onTopPassedReverse' },
topVisible: { callback: onTopVisibleReverse, name: 'onTopVisibleReverse' },
}
_.invoke(this.props, 'onUpdate', null, { ...this.props, calculations: this.calculations })
this.fireOnPassed()
// Heads up! Reverse callbacks should be fired first
_.forEach(reverse, (data, value) => this.fire(data, value, true))
_.forEach(forward, (data, value) => this.fire(data, value))
if (updateOn === 'repaint') this.handleUpdate()
}
// ----------------------------------------
// Helpers
// ----------------------------------------
computeCalculations() {
const { offset } = this.props
const { bottom, height, top, width } = this.ref.current.getBoundingClientRect()
const [topOffset, bottomOffset] = normalizeOffset(offset)
const newOffset = this.getPageYOffset()
const direction = newOffset > this.pageYOffset ? 'down' : 'up'
const topPassed = top < topOffset
const bottomPassed = bottom < bottomOffset
const pixelsPassed = bottomPassed ? 0 : Math.max(top * -1, 0)
const percentagePassed = pixelsPassed / height
const bottomVisible = bottom >= bottomOffset && bottom <= window.innerHeight
const topVisible = top >= topOffset && top <= window.innerHeight
const fits = topVisible && bottomVisible
const passing = topPassed && !bottomPassed
const onScreen = (topVisible || topPassed) && !bottomPassed
const offScreen = !onScreen
return {
bottomPassed,
bottomVisible,
direction,
fits,
height,
passing,
percentagePassed,
pixelsPassed,
offScreen,
onScreen,
topPassed,
topVisible,
width,
}
}
getPageYOffset() {
const { context } = this.props
if (context) {
// Heads up! `window` doesn't have `pageYOffset` property
return context === window ? window.pageYOffset : context.scrollTop
}
return 0
}
// ----------------------------------------
// Render
// ----------------------------------------
render() {
const { children } = this.props
const ElementType = getElementType(Visibility, this.props)
const rest = getUnhandledProps(Visibility, this.props)
return (
<Ref innerRef={this.ref}>
<ElementType {...rest}>{children}</ElementType>
</Ref>
)
}
}
Visibility.propTypes = {
/** An element type to render as (string or function). */
as: PropTypes.elementType,
/** Primary content. */
children: PropTypes.node,
/** Context which visibility should attach onscroll events. */
context: PropTypes.object,
/**
* When set to true a callback will occur anytime an element passes a condition not just immediately after the
* threshold is met.
*/
continuous: PropTypes.bool,
/** Fires callbacks immediately after mount. */
fireOnMount: PropTypes.bool,
/**
* Element's bottom edge has passed top of screen.
*
* @param {null}
* @param {object} data - All props.
*/
onBottomPassed: PropTypes.func,
/**
* Element's bottom edge has not passed top of screen.
*
* @param {null}
* @param {object} data - All props.
*/
onBottomPassedReverse: PropTypes.func,
/**
* Element's bottom edge has passed bottom of screen
*
* @param {null}
* @param {object} data - All props.
*/
onBottomVisible: PropTypes.func,
/**
* Element's bottom edge has not passed bottom of screen.
*
* @param {null}
* @param {object} data - All props.
*/
onBottomVisibleReverse: PropTypes.func,
/**
* Value that context should be adjusted in pixels. Useful for making content appear below content fixed to the
* page.
*/
offset: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
]),
/** When set to false a callback will occur each time an element passes the threshold for a condition. */
once: PropTypes.bool,
/** Element is not visible on the screen. */
onPassed: PropTypes.object,
/**
* Any part of an element is visible on screen.
*
* @param {null}
* @param {object} data - All props.
*/
onPassing: PropTypes.func,
/**
* Element's top has not passed top of screen but bottom has.
*
* @param {null}
* @param {object} data - All props.
*/
onPassingReverse: PropTypes.func,
/**
* Element is not visible on the screen.
*
* @param {null}
* @param {object} data - All props.
*/
onOffScreen: PropTypes.func,
/**
* Element is visible on the screen.
*
* @param {null}
* @param {object} data - All props.
*/
onOnScreen: PropTypes.func,
/**
* Element's top edge has passed top of the screen.
*
* @param {null}
* @param {object} data - All props.
*/
onTopPassed: PropTypes.func,
/**
* Element's top edge has not passed top of the screen.
*
* @param {null}
* @param {object} data - All props.
*/
onTopPassedReverse: PropTypes.func,
/**
* Element's top edge has passed bottom of screen.
*
* @param {null}
* @param {object} data - All props.
*/
onTopVisible: PropTypes.func,
/**
* Element's top edge has not passed bottom of screen.
*
* @param {null}
* @param {object} data - All props.
*/
onTopVisibleReverse: PropTypes.func,
/**
* Element's top edge has passed bottom of screen.
*
* @param {null}
* @param {object} data - All props.
*/
onUpdate: PropTypes.func,
/**
* Allows to choose the mode of the position calculations:
* - `events` - (default) update and fire callbacks only on scroll/resize events
* - `repaint` - update and fire callbacks on browser repaint (animation frames)
*/
updateOn: PropTypes.oneOf(['events', 'repaint']),
}
Visibility.defaultProps = {
context: isBrowser() ? window : null,
continuous: false,
offset: [0, 0],
once: true,
updateOn: 'events',
}