react-lanelayout
Version:
A react component to display items with specific aspect ratios in horizontal or vertical lanes.
672 lines (566 loc) • 21 kB
JavaScript
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
import compact from "lodash/compact";
import map from "lodash/map";
import last from "lodash/last";
import max from "lodash/max";
import isNumber from "lodash/isNumber";
import isBoolean from "lodash/isBoolean";
import defer from "lodash/defer";
import React from "react";
import PropTypes from "prop-types";
import normalizeWheel from "normalize-wheel";
import ReactResizeDetector from "react-resize-detector";
import VisibilitySensor from "react-visibility-sensor";
var LaneLayout = function (_React$Component) {
_inherits(LaneLayout, _React$Component);
function LaneLayout() {
_classCallCheck(this, LaneLayout);
/**
* References to DOM-Elements
*/
var _this = _possibleConstructorReturn(this, (LaneLayout.__proto__ || Object.getPrototypeOf(LaneLayout)).call(this));
_this.domrefs = {
container: React.createRef(),
list: React.createRef()
};
_this.state = {
/* Did the component mount in the browser already? */
mounted: false,
/* Current scrollTop of container */
scrollTop: 0,
/* Current scrollLeft of container */
scrollLeft: 0,
/* the scroll-progress (0-1) */
scrollProgress: 0,
/* the key of the last hovered item */
hoverItem: null
};
_this._containerStyles = _this._containerStyles.bind(_this);
_this._listStyles = _this._listStyles.bind(_this);
_this._renderItem = _this._renderItem.bind(_this);
_this._itemStyles = _this._itemStyles.bind(_this);
_this._laneCount = _this._laneCount.bind(_this);
_this._pickLane = _this._pickLane.bind(_this);
_this._itemTop = _this._itemTop.bind(_this);
_this._itemHeight = _this._itemHeight.bind(_this);
_this._itemWidth = _this._itemWidth.bind(_this);
_this._itemSize = _this._itemSize.bind(_this);
_this._onScrollEnd = _this._onScrollEnd.bind(_this);
_this._fixListDimensions = _this._fixListDimensions.bind(_this);
_this._isVisibleItem = _this._isVisibleItem.bind(_this);
_this._onScroll = _this._onScroll.bind(_this);
_this._onWheel = _this._onWheel.bind(_this);
_this._onResize = _this._onResize.bind(_this);
_this._autoScroll = _this._autoScroll.bind(_this);
return _this;
}
_createClass(LaneLayout, [{
key: "componentDidMount",
value: function componentDidMount() {
var autoScroll = this.props.autoScroll;
this.setState({
mounted: true
});
this.domrefs.container.current.addEventListener(normalizeWheel.getEventType(), this._onWheel, false);
this.domrefs.container.current.addEventListener("scroll", this._onScroll, false);
autoScroll && this._autoScroll();
}
}, {
key: "componentWillUnmount",
value: function componentWillUnmount() {
this.domrefs.container.current.removeEventListener(normalizeWheel.getEventType(), this._onWheel, false);
this.domrefs.container.current.removeEventListener("scroll",
//this._onScroll,
this._onScroll, false);
}
}, {
key: "componentWillReceiveProps",
value: function componentWillReceiveProps(newProps) {
if (newProps.autoScroll !== this.props.autoScroll) {
if (isNumber(newProps.autoScroll) || isBoolean(newProps.autoScroll)) {
defer(this._autoScroll);
}
}
if (newProps.horizontal !== this.props.horizontal) {
defer(this._onResize);
}
}
}, {
key: "_autoScroll",
value: function _autoScroll() {
var _props = this.props,
horizontal = _props.horizontal,
autoScroll = _props.autoScroll;
var _state = this.state,
scrollLeft = _state.scrollLeft,
scrollTop = _state.scrollTop;
var container = this.domrefs.container.current;
var scrollSpeed = isNumber(autoScroll) ? Math.abs(autoScroll) : 1;
if (horizontal && container) {
container.scrollLeft = scrollLeft + scrollSpeed;
} else if (container) {
container.scrollTop = scrollTop + scrollSpeed;
}
if (autoScroll === true || isNumber(autoScroll) && autoScroll !== 0) {
window.requestAnimationFrame(this._autoScroll);
}
}
}, {
key: "_onWheel",
value: function _onWheel(e) {
e.stopPropagation();
e.preventDefault();
var horizontal = this.props.horizontal;
var container = this.domrefs.container.current;
var normalized = normalizeWheel(e);
var pixelY = normalized.pixelY;
var prop = horizontal ? "scrollLeft" : "scrollTop";
container[prop] = container[prop] + pixelY;
}
}, {
key: "_onScroll",
value: function _onScroll() {
var horizontal = this.props.horizontal;
var _domrefs$container$cu = this.domrefs.container.current,
scrollTop = _domrefs$container$cu.scrollTop,
scrollLeft = _domrefs$container$cu.scrollLeft,
scrollHeight = _domrefs$container$cu.scrollHeight,
scrollWidth = _domrefs$container$cu.scrollWidth,
offsetWidth = _domrefs$container$cu.offsetWidth,
offsetHeight = _domrefs$container$cu.offsetHeight;
scrollTop = scrollTop === 0 ? this.state.scrollProgress * scrollHeight : scrollTop;
scrollLeft = scrollLeft === 0 ? this.state.scrollProgress * scrollWidth : scrollLeft;
var scrollMax = horizontal ? scrollWidth - offsetWidth : scrollHeight - offsetHeight;
var scrollProgress = 1 / scrollMax * (horizontal ? scrollLeft : scrollTop);
this.setState({
scrollLeft: scrollLeft,
scrollTop: scrollTop,
scrollProgress: scrollProgress
});
}
/**
* Approximate previous scroll-position after container was resized
*/
}, {
key: "_onResize",
value: function _onResize() {
var _this2 = this;
this.setState({
lastResize: new Date().getTime()
});
defer(function () {
var horizontal = _this2.props.horizontal;
var scrollProgress = _this2.state.scrollProgress;
var container = _this2.domrefs.container.current;
var scrollHeight = container.scrollHeight,
scrollWidth = container.scrollWidth,
offsetHeight = container.offsetHeight,
offsetWidth = container.offsetWidth;
if (horizontal) {
// Find a solution
container.scrollLeft = scrollProgress * (scrollWidth - offsetWidth);
} else {
container.scrollTop = scrollProgress * (scrollHeight - offsetHeight);
}
});
}
/**
* Returns the Containers' CSS
*/
}, {
key: "_containerStyles",
value: function _containerStyles() {
var _props2 = this.props,
debug = _props2.debug,
horizontal = _props2.horizontal;
return {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
width: "100%",
height: "100%",
outline: debug && "1px solid tomato",
transform: "translate3d(0,0,0)",
overflow: "auto",
overflowX: !horizontal && 'hidden',
overflowY: horizontal && 'hidden',
/** iOS specific scroll behavior instructions */
overflowScrolling: "touch",
WebkitOverflowScrolling: "touch"
};
}
/**
* Returns the Item-Containers' CSS
*/
}, {
key: "_listStyles",
value: function _listStyles() {
var debug = this.props.debug;
return {
padding: 0,
margin: 0,
listStyle: "none",
outline: debug && "1px solid tomato",
transform: "translate3d(0,0,0)"
};
}
/**
* Returns the index of the lane which should receive the next item
*/
}, {
key: "_pickLane",
value: function _pickLane() {
var horizontal = this.props.horizontal;
var prop1 = horizontal ? "width" : "height";
var prop2 = horizontal ? "left" : "top";
var laneDimensions = this.lanes.map(function (items) {
var lastItem = items[items.length - 1];
return lastItem ? lastItem[prop1] + lastItem[prop2] : 0;
});
return laneDimensions.indexOf(Math.min.apply(Math, _toConsumableArray(laneDimensions)));
}
}, {
key: "_itemHeight",
value: function _itemHeight(_ref) {
var laneCount = _ref.laneCount;
var _props3 = this.props,
gutter = _props3.gutter,
outerGutter = _props3.outerGutter;
var containerHeight = this.domrefs.container.current.offsetHeight - (laneCount - 1) * gutter - (outerGutter ? 2 * gutter : 0);
return Math.floor(containerHeight / laneCount);
}
}, {
key: "_itemWidth",
value: function _itemWidth(_ref2) {
var laneCount = _ref2.laneCount;
var _props4 = this.props,
gutter = _props4.gutter,
outerGutter = _props4.outerGutter;
var containerWidth = this.domrefs.container.current.offsetWidth - (laneCount - 1) * gutter - (outerGutter ? 2 * gutter : 0);
return Math.floor(containerWidth / laneCount);
}
}, {
key: "_itemSize",
value: function _itemSize(props) {
var item = props.item;
var horizontal = this.props.horizontal;
var height = void 0,
width = void 0;
if (horizontal) {
height = this._itemHeight(props);
width = height * item.ratio;
} else {
width = this._itemWidth(props);
height = Math.floor(1 / item.ratio * width);
}
return {
width: width,
height: height
};
}
}, {
key: "_itemLeft",
value: function _itemLeft(_ref3) {
var laneIndex = _ref3.laneIndex,
width = _ref3.width,
height = _ref3.height;
var horizontal = this.props.horizontal;
var _props5 = this.props,
gutter = _props5.gutter,
outerGutter = _props5.outerGutter;
// Horizontal
if (horizontal) {
var items = this.lanes[laneIndex];
var item = last(items);
return items && items.length ? item.left + item.width + gutter : outerGutter ? gutter : 0;
} else {
// Vertical
return laneIndex * width + laneIndex * gutter + (outerGutter ? gutter : 0);
}
}
}, {
key: "_itemTop",
value: function _itemTop(props) {
var laneIndex = props.laneIndex,
height = props.height;
var _props6 = this.props,
horizontal = _props6.horizontal,
gutter = _props6.gutter,
outerGutter = _props6.outerGutter;
var items = this.lanes[laneIndex];
var item = last(items);
if (horizontal) {
if (items.length) {
return last(items).top;
}
return height * laneIndex + laneIndex * gutter + (outerGutter ? gutter : 0);
} else {
return items.length ? item.top + item.height + gutter : outerGutter ? gutter : 0;
}
}
/**
* Return styles for the specified item
*/
}, {
key: "_itemStyles",
value: function _itemStyles(_ref4) {
var index = _ref4.index,
item = _ref4.item,
maxIndex = _ref4.maxIndex,
laneCount = _ref4.laneCount;
var debug = this.props.debug;
var laneIndex = this._pickLane();
var payload = { index: index, item: item, maxIndex: maxIndex, laneIndex: laneIndex, laneCount: laneCount };
var dimensions = this._itemSize(payload);
var css = Object.assign({
outline: debug && "1px solid tomato",
padding: 0,
margin: 0,
position: "absolute",
transform: "translate3d(0,0,0)"
}, dimensions, {
top: this._itemTop(Object.assign({}, payload, dimensions)),
left: this._itemLeft(Object.assign({}, payload, dimensions))
});
this.lanes[laneIndex] = [].concat(_toConsumableArray(this.lanes[laneIndex]), [css]);
return css;
}
/**
* Sets the height/width of the list according to its tallest/widest lane
*/
}, {
key: "_fixListDimensions",
value: function _fixListDimensions() {
var _domrefs$container$cu2 = this.domrefs.container.current,
offsetHeight = _domrefs$container$cu2.offsetHeight,
offsetWidth = _domrefs$container$cu2.offsetWidth;
var _props7 = this.props,
horizontal = _props7.horizontal,
gutter = _props7.gutter,
outerGutter = _props7.outerGutter;
var el = this.domrefs.list.current;
var lastItems = this.lanes.map(function (items) {
var item = items[items.length - 1];
return horizontal ? item.left + item.width : item.top + item.height;
});
var size = max(lastItems) + (outerGutter ? gutter : 0);
el.style.width = horizontal ? size + "px" : offsetWidth + "px";
el.style.height = horizontal ? offsetHeight + "px" : size + "px";
}
/**
* Checks wether the item which should be rendered is
* actually visible on the screen
*/
}, {
key: "_isVisibleItem",
value: function _isVisibleItem(_ref5) {
var top = _ref5.top,
left = _ref5.left,
width = _ref5.width,
height = _ref5.height;
var horizontal = this.props.horizontal;
var _state2 = this.state,
scrollTop = _state2.scrollTop,
scrollLeft = _state2.scrollLeft;
var _domrefs$container$cu3 = this.domrefs.container.current,
offsetHeight = _domrefs$container$cu3.offsetHeight,
offsetWidth = _domrefs$container$cu3.offsetWidth;
if (horizontal) {
var leftVisible = left >= scrollLeft - width;
var rightVisible = left <= scrollLeft + offsetWidth;
return leftVisible && rightVisible;
} else {
var topVisible = top >= scrollTop - height;
var bottomVisible = top <= scrollTop + offsetHeight;
return topVisible && bottomVisible;
}
}
}, {
key: "_renderItem",
value: function _renderItem(_ref6) {
var _this3 = this;
var index = _ref6.index,
item = _ref6.item,
maxIndex = _ref6.maxIndex,
laneCount = _ref6.laneCount;
var hoverItem = this.state.hoverItem;
var itemRenderer = this.props.itemRenderer;
var Component = itemRenderer;
var style = this._itemStyles({ index: index, item: item, maxIndex: maxIndex, laneCount: laneCount });
var isVisible = this._isVisibleItem(style);
if (index === maxIndex) {
this._fixListDimensions({});
}
if (hoverItem === item.key) {
style.zIndex = "1";
}
return isVisible ? React.createElement(
"li",
{
key: item.key,
style: style,
onMouseEnter: function onMouseEnter() {
return _this3.setState({ hoverItem: item.key });
}
},
React.createElement(Component, item.itemProps),
index === maxIndex && this._renderVisibilityChecker()
) : null;
}
/**
* Renders a helper component which tells us
* when we reached the end of the scrollable area
*/
}, {
key: "_renderVisibilityChecker",
value: function _renderVisibilityChecker() {
return React.createElement(VisibilitySensor, {
containment: this.domrefs.container.current,
onChange: this._onScrollEnd
});
}
/**
* Triggered when user reaches the end of the scrollable area
*/
}, {
key: "_onScrollEnd",
value: function _onScrollEnd() {
this.props.onEnd && this.props.onEnd();
}
/**
* Returns the amount of lanes configured
*/
}, {
key: "_laneCount",
value: function _laneCount() {
var _props8 = this.props,
lanes = _props8.lanes,
horizontal = _props8.horizontal;
var config = lanes[horizontal ? "horizontal" : "vertical"];
var prop = horizontal ? "offsetHeight" : "offsetWidth";
var containerWidth = this.domrefs.container.current[prop];
var mqs = compact(map(config, function (laneCount, key) {
var applies = containerWidth >= key;
return applies ? laneCount : false;
}));
var colcount = last(mqs);
return colcount;
}
}, {
key: "render",
value: function render() {
var _this4 = this;
var _props$items = this.props.items,
items = _props$items === undefined ? [] : _props$items;
var mounted = this.state.mounted;
if (!items.length) {
return null;
}
var laneCount = mounted && this._laneCount();
if (mounted) {
this.lanes = new Array(laneCount).fill([]);
}
var maxIndex = items.length ? items.length - 1 : 0;
return React.createElement(
"div",
{ ref: this.domrefs.container, style: this._containerStyles() },
React.createElement(
"ul",
{ ref: this.domrefs.list, style: this._listStyles() },
mounted && items.map(function (item, index) {
return _this4._renderItem({ index: index, item: item, maxIndex: maxIndex, laneCount: laneCount });
})
),
React.createElement(ReactResizeDetector, {
handleWidth: true,
handleHeight: true,
onResize: this._onResize
})
);
}
}]);
return LaneLayout;
}(React.Component);
LaneLayout.propTypes = {
/**
* Config object for responsive behavior
*/
lanes: PropTypes.shape({
/**
* vertical breakpoints (key = min-height, value = amount of rows)
*/
vertical: PropTypes.object.isRequired,
/**
* horizontal breakpoints (key = min-width, value = amount of cols)
*/
horizontal: PropTypes.object.isRequired
}).isRequired,
/* enable debug outlines */
debug: PropTypes.bool,
/* set mode to horizontal lanes */
horizontal: PropTypes.bool,
/* spacing between items */
gutter: PropTypes.number,
/* apply gutter on container sides/ends */
outerGutter: PropTypes.bool,
/* the items which are supposed to be displayed */
items: PropTypes.arrayOf(PropTypes.shape({
/**
* a unique identifier for this item
*/
key: PropTypes.string.isRequired,
/**
* the width/height ratio of the item
*/
ratio: PropTypes.number.isRequired,
/**
* the props to be passed to the itemRenderer component
*/
itemProps: PropTypes.object.isRequired
})).isRequired,
/**
* function/component used to render an item
*/
itemRenderer: PropTypes.func,
/**
* function to be called when reaching the end of the scrollable area
*/
onEnd: PropTypes.func,
autoScroll: PropTypes.oneOfType([PropTypes.bool, PropTypes.number])
};
LaneLayout.defaultProps = {
debug: false,
horizontal: false,
gutter: 16,
outerGutter: true,
itemRenderer: function itemRenderer() {
return null;
},
onEnd: function onEnd() {
return null;
},
autoScroll: false,
lanes: {
vertical: {
0: 1, // 1 lane if the component is less than 480px wide
480: 2, // 2 lanes if the component is min. 480px wide
720: 3, // 3 lanes if the component is min. 720px wide
960: 4,
1200: 5
},
horizontal: {
0: 1, // 1 lane when the component is less than 480px in height
480: 2, // 2 lanes when the component is min 480px in height
720: 3, // 3 lanes when the component is min 720px in height
960: 4,
1200: 5
}
}
};
export default LaneLayout;