focus-components-v3
Version:
Focus web components to build applications (based on Material Design)
266 lines (242 loc) • 9.24 kB
JavaScript
import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom';
import BackToTop from '../button-back-to-top'
import StickyMenu from './sticky-menu';
import Scroll from '../behaviours/scroll';
import Grid from '../grid';
import Column from '../column';
import debounce from 'lodash/debounce';
import filter from 'lodash/filter';
import first from 'lodash/first';
import last from 'lodash/last';
import xor from 'lodash/xor';
const BackToTopComponent = BackToTop;
// component default props.
const defaultProps = {
hasMenu: true, //Activate the presence of the sticky navigation component.
hasBackToTop: true, //Activate the presence of BackToTop button
offset: 100, //offset position when affix
scrollDelay: 10 //defaut debounce delay for scroll spy call
};
// component props definition.
const propTypes = {
hasMenu: PropTypes.bool,
hasBackToTop: PropTypes.bool,
offset: PropTypes.number,
scrollDelay: PropTypes.number
};
/**
* ScrollspyContainer component.
*/
class ScrollspyContainer extends Component {
constructor(props) {
super(props);
const state = {
menuList: [],
affix: false
};
this.state = state;
}
/** @inheritDoc */
componentDidMount() {
this._scrollCarrier = window;
this._debouncedRefresh = debounce(this._refreshMenu, this.props.scrollDelay);
this._scrollCarrier.addEventListener('scroll', this._debounceRefreshMenu);
this._scrollCarrier.addEventListener('resize', this._debounceRefreshMenu);
this._executeRefreshMenu(10);
}
/** @inheritDoc */
componentWillUnmount() {
this._timeouts.map(clearTimeout);
this._scrollCarrier.removeEventListener('scroll', this._debounceRefreshMenu);
this._scrollCarrier.removeEventListener('resize', this._debounceRefreshMenu);
this._debouncedRefresh.cancel();
}
/**
* Refresh screen X times.
* @param {number} time number of execution
*/
_executeRefreshMenu = time => {
this._timeouts = [];
for (let i = 0; i < time; i++) {
this._timeouts.push(setTimeout(this._refreshMenu.bind(this), i * 1000));
}
};
_debounceRefreshMenu = () => {
this._debouncedRefresh();
};
/**
* The scroll event handler
* @private
*/
_refreshMenu = () => {
if(!this.props.hasMenu) { return; }
const {stickyMenu} = this.refs;
const {clickedId} = this.state;
const menus = this._buildMenuList(); //build the menu list
//TODO remove this check
const affix = stickyMenu ? this._isMenuAffix() : this.state.affix; //Calculate menu position (affix or not)
// Check if scroll is at cliked item level
let isAtClickedItem;
if (clickedId !== undefined) {
const selector = `[data-spy='${clickedId}']`;
const node = document.querySelector(selector);
const nodePosition = this.scrollPosition(node);
const positionTop = this._getElementRealPosition(nodePosition.top);
isAtClickedItem = this.scrollPosition().top === positionTop;
}
this.setState({
menuList: menus,
clickedId: isAtClickedItem ? undefined : clickedId,
affix
});
};
/**
* Build the list of menus.
* @private
* @return {array} the list of menus.
*/
_buildMenuList = () => {
const {hasMenu} = this.props;
if(!hasMenu) {
return [];
}
const detectionOffset = window.screen.height / 5;
let currentScrollPosition = {top: this.scrollPosition().top, left: this.scrollPosition().left};
let isAtPageBottom = this.isAtPageBottom();
//get the menu list (without blocks in popin)
const thisComponentNode = ReactDOM.findDOMNode(this);
const allDataSpy = thisComponentNode.querySelectorAll('[data-spy]');
const popinDataSpy = thisComponentNode.querySelectorAll(`[data-focus='popin-window'] [data-spy]`);
const selectionList = xor(allDataSpy, popinDataSpy);
if(selectionList.length === 0) {
return;
}
let menuList = selectionList.map((selection, index) => {
const title = selection.querySelector('[data-spy-title]');
const nodeId = selection.getAttribute('data-spy');
return {
index: index,
label: title.innerHTML,
nodeId: nodeId,
scrollTop: this.scrollPosition(selection).top, // offset of 10 to be safe
isActive: false,
onClick: this._getMenuItemClickHandler(nodeId)
};
});
const nextTitles = filter(menuList, n => (currentScrollPosition.top + detectionOffset < this._getElementRealPosition(n.scrollTop)));
//Calculate current node
//by default, first node is indexed
let currentIndex = menuList[0].index;
if(0 < nextTitles.length) {
//check the first node
const firstNode = first(nextTitles);
const index = firstNode.index;
if(0 < index) {
currentIndex = menuList[index - 1].index;
}
} else {
//means that the position is the last title
currentIndex = last(menuList).index;
}
let clickedId = this.state.clickedId;
if(isAtPageBottom && undefined !== clickedId) {
menuList = menuList.map(item => {
if (item.nodeId === clickedId) {
item.isActive = true;
}
return item;
});
this.setState({clickedId: undefined});
}else {
menuList[currentIndex].isActive = true;
}
return menuList;
};
/**
* Calculate the real position of an element, depending on declared offset in props.
* @private
* @param {number} position position
* @return {number} the real position
*/
_getElementRealPosition = (position) => {
const sscDomNode = ReactDOM.findDOMNode(this);
const sscPosition = this.scrollPosition(sscDomNode);
return position - sscPosition.top;
};
/**
* Calculate menu position (affix or not)
* @private
* @return {Boolean} true is menu must be affix, else false
*/
_isMenuAffix = () => {
let {offset} = this.props;
const {hasMenu} = this.props;
if(!hasMenu) {
return false;
}
const sscDomNode = ReactDOM.findDOMNode(this);
const currentViewPosition = sscDomNode.getBoundingClientRect();
const containerPaddingTop = this._getPaddingTopValue();
offset -= containerPaddingTop;
return currentViewPosition.top <= offset;
};
_getPaddingTopValue = () => {
const sscDomNode = ReactDOM.findDOMNode(this);
const computedStyles = window.getComputedStyle(sscDomNode, null);
const paddingTop = computedStyles.getPropertyValue('padding-top');
return paddingTop ? parseInt(paddingTop, 0) : 0;
};
/**
* Handle click on item menu function.
* @private
* @param {string} menuId node spyId in DOM to scroll to
* @return {function} function to call
*/
_getMenuItemClickHandler(menuId) {
return () => {
this.setState({
clickedId: menuId
}, () => {
this._refreshMenu();
this._onMenuItemClick(menuId);
});
}
}
/**
* Menu click function. Scroll to the node position.
* @private
* @param {string} menuId node spyId in DOM to scroll to
*/
_onMenuItemClick(menuId) {
const selector = `[data-spy='${menuId}']`;
const node = document.querySelector(selector);
const nodePosition = this.scrollPosition(node);
const positionTop = this._getElementRealPosition(nodePosition.top);
this.scrollTo(undefined, positionTop);
}
/** @inheritedDoc */
render() {
const {children, hasMenu, hasBackToTop, offset, scrollDelay, ...otherProps} = this.props;
const {affix, menuList} = this.state;
return (
<div data-focus='scrollspy-container' {...otherProps}>
{hasMenu &&
<StickyMenu affix={affix} affixOffset={offset} menuList={menuList} ref='stickyMenu' />
}
<div data-focus='scrollspy-container-content'>
{children}
</div>
{hasBackToTop &&
<BackToTopComponent />
}
</div>
);
}
}
//Static props.
ScrollspyContainer.displayName = 'ScrollspyContainer';
ScrollspyContainer.defaultProps = defaultProps;
ScrollspyContainer.propTypes = propTypes;
export default ScrollspyContainer;