UNPKG

react-lanelayout

Version:

A react component to display items with specific aspect ratios in horizontal or vertical lanes.

672 lines (566 loc) 21 kB
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;