react-visibility-sensor
Version:
Sensor component for React that notifies you when it goes in or out of the window viewport.
337 lines (289 loc) • 8.78 kB
JavaScript
"use strict";
import React from "react";
import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import isVisibleWithOffset from "./lib/is-visible-with-offset";
function normalizeRect(rect) {
if (rect.width === undefined) {
rect.width = rect.right - rect.left;
}
if (rect.height === undefined) {
rect.height = rect.bottom - rect.top;
}
return rect;
}
export default class VisibilitySensor extends React.Component {
static defaultProps = {
active: true,
partialVisibility: false,
minTopValue: 0,
scrollCheck: false,
scrollDelay: 250,
scrollThrottle: -1,
resizeCheck: false,
resizeDelay: 250,
resizeThrottle: -1,
intervalCheck: true,
intervalDelay: 100,
delayedCall: false,
offset: {},
containment: null,
children: <span />
};
static propTypes = {
onChange: PropTypes.func,
active: PropTypes.bool,
partialVisibility: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.oneOf(["top", "right", "bottom", "left"])
]),
delayedCall: PropTypes.bool,
offset: PropTypes.oneOfType([
PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
bottom: PropTypes.number,
right: PropTypes.number
}),
// deprecated offset property
PropTypes.shape({
direction: PropTypes.oneOf(["top", "right", "bottom", "left"]),
value: PropTypes.number
})
]),
scrollCheck: PropTypes.bool,
scrollDelay: PropTypes.number,
scrollThrottle: PropTypes.number,
resizeCheck: PropTypes.bool,
resizeDelay: PropTypes.number,
resizeThrottle: PropTypes.number,
intervalCheck: PropTypes.bool,
intervalDelay: PropTypes.number,
containment:
typeof window !== "undefined"
? PropTypes.instanceOf(window.Element)
: PropTypes.any,
children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
minTopValue: PropTypes.number
};
constructor(props) {
super(props);
this.state = {
isVisible: null,
visibilityRect: {}
};
}
componentDidMount() {
this.node = ReactDOM.findDOMNode(this);
if (this.props.active) {
this.startWatching();
}
}
componentWillUnmount() {
this.stopWatching();
}
componentDidUpdate(prevProps) {
// re-register node in componentDidUpdate if children diffs [#103]
this.node = ReactDOM.findDOMNode(this);
if (this.props.active && !prevProps.active) {
this.setState({
isVisible: null,
visibilityRect: {}
});
this.startWatching();
} else if (!this.props.active) {
this.stopWatching();
}
}
getContainer = () => {
return this.props.containment || window;
};
addEventListener = (target, event, delay, throttle) => {
if (!this.debounceCheck) {
this.debounceCheck = {};
}
let timeout;
let func;
const later = () => {
timeout = null;
this.check();
};
if (throttle > -1) {
func = () => {
if (!timeout) {
timeout = setTimeout(later, throttle || 0);
}
};
} else {
func = () => {
clearTimeout(timeout);
timeout = setTimeout(later, delay || 0);
};
}
const info = {
target: target,
fn: func,
getLastTimeout: () => {
return timeout;
}
};
target.addEventListener(event, info.fn);
this.debounceCheck[event] = info;
};
startWatching = () => {
if (this.debounceCheck || this.interval) {
return;
}
if (this.props.intervalCheck) {
this.interval = setInterval(this.check, this.props.intervalDelay);
}
if (this.props.scrollCheck) {
this.addEventListener(
this.getContainer(),
"scroll",
this.props.scrollDelay,
this.props.scrollThrottle
);
}
if (this.props.resizeCheck) {
this.addEventListener(
window,
"resize",
this.props.resizeDelay,
this.props.resizeThrottle
);
}
// if dont need delayed call, check on load ( before the first interval fires )
!this.props.delayedCall && this.check();
};
stopWatching = () => {
if (this.debounceCheck) {
// clean up event listeners and their debounce callers
for (let debounceEvent in this.debounceCheck) {
if (this.debounceCheck.hasOwnProperty(debounceEvent)) {
const debounceInfo = this.debounceCheck[debounceEvent];
clearTimeout(debounceInfo.getLastTimeout());
debounceInfo.target.removeEventListener(
debounceEvent,
debounceInfo.fn
);
this.debounceCheck[debounceEvent] = null;
}
}
}
this.debounceCheck = null;
if (this.interval) {
this.interval = clearInterval(this.interval);
}
};
roundRectDown(rect) {
return {
top: Math.floor(rect.top),
left: Math.floor(rect.left),
bottom: Math.floor(rect.bottom),
right: Math.floor(rect.right)
};
}
/**
* Check if the element is within the visible viewport
*/
check = () => {
const el = this.node;
let rect;
let containmentRect;
// if the component has rendered to null, dont update visibility
if (!el) {
return this.state;
}
rect = normalizeRect(this.roundRectDown(el.getBoundingClientRect()));
if (this.props.containment) {
const containmentDOMRect = this.props.containment.getBoundingClientRect();
containmentRect = {
top: containmentDOMRect.top,
left: containmentDOMRect.left,
bottom: containmentDOMRect.bottom,
right: containmentDOMRect.right
};
} else {
containmentRect = {
top: 0,
left: 0,
bottom: window.innerHeight || document.documentElement.clientHeight,
right: window.innerWidth || document.documentElement.clientWidth
};
}
// Check if visibility is wanted via offset?
const offset = this.props.offset || {};
const hasValidOffset = typeof offset === "object";
if (hasValidOffset) {
containmentRect.top += offset.top || 0;
containmentRect.left += offset.left || 0;
containmentRect.bottom -= offset.bottom || 0;
containmentRect.right -= offset.right || 0;
}
const visibilityRect = {
top: rect.top >= containmentRect.top,
left: rect.left >= containmentRect.left,
bottom: rect.bottom <= containmentRect.bottom,
right: rect.right <= containmentRect.right
};
// https://github.com/joshwnj/react-visibility-sensor/pull/114
const hasSize = rect.height > 0 && rect.width > 0;
let isVisible =
hasSize &&
visibilityRect.top &&
visibilityRect.left &&
visibilityRect.bottom &&
visibilityRect.right;
// check for partial visibility
if (hasSize && this.props.partialVisibility) {
let partialVisible =
rect.top <= containmentRect.bottom &&
rect.bottom >= containmentRect.top &&
rect.left <= containmentRect.right &&
rect.right >= containmentRect.left;
// account for partial visibility on a single edge
if (typeof this.props.partialVisibility === "string") {
partialVisible = visibilityRect[this.props.partialVisibility];
}
// if we have minimum top visibility set by props, lets check, if it meets the passed value
// so if for instance element is at least 200px in viewport, then show it.
isVisible = this.props.minTopValue
? partialVisible &&
rect.top <= containmentRect.bottom - this.props.minTopValue
: partialVisible;
}
// Deprecated options for calculating offset.
if (
typeof offset.direction === "string" &&
typeof offset.value === "number"
) {
console.warn(
"[notice] offset.direction and offset.value have been deprecated. They still work for now, but will be removed in next major version. Please upgrade to the new syntax: { %s: %d }",
offset.direction,
offset.value
);
isVisible = isVisibleWithOffset(offset, rect, containmentRect);
}
let state = this.state;
// notify the parent when the value changes
if (this.state.isVisible !== isVisible) {
state = {
isVisible: isVisible,
visibilityRect: visibilityRect
};
this.setState(state);
if (this.props.onChange) this.props.onChange(isVisible);
}
return state;
};
render() {
if (this.props.children instanceof Function) {
return this.props.children({
isVisible: this.state.isVisible,
visibilityRect: this.state.visibilityRect
});
}
return React.Children.only(this.props.children);
}
}