|
|
|
@ -1,179 +1,181 @@
|
|
|
|
|
/* @flow */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// https://github.com/KyleAMathews/react-headroom/blob/master/src/shouldUpdate.js
|
|
|
|
|
|
|
|
|
|
import * as React from 'react';
|
|
|
|
|
import raf from 'raf';
|
|
|
|
|
import { getViewportHeight, getScrollY } from 'utils/windowUtils';
|
|
|
|
|
import { getViewportHeight, getScrollX, getScrollY } from 'utils/windowUtils';
|
|
|
|
|
import styled from 'styled-components';
|
|
|
|
|
import colors from 'config/colors';
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
location: string,
|
|
|
|
|
deviceSelection: boolean,
|
|
|
|
|
children?: React.Node,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AsideWrapper = styled.aside`
|
|
|
|
|
type State = {
|
|
|
|
|
prevScrollY: number;
|
|
|
|
|
asideMinHeight: number,
|
|
|
|
|
wrapperTopOffset: number,
|
|
|
|
|
wrapperLeftOffset: number,
|
|
|
|
|
wrapperBottomPadding: number,
|
|
|
|
|
footerFixed: boolean,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AsideWrapper = styled.aside.attrs({
|
|
|
|
|
style: ({ minHeight }) => ({
|
|
|
|
|
minHeight,
|
|
|
|
|
}),
|
|
|
|
|
})`
|
|
|
|
|
position: relative;
|
|
|
|
|
top: 0;
|
|
|
|
|
top: 0px;
|
|
|
|
|
width: 320px;
|
|
|
|
|
min-width: 320px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
background: ${colors.MAIN};
|
|
|
|
|
border-right: 1px solid ${colors.DIVIDER};
|
|
|
|
|
|
|
|
|
|
.fixed {
|
|
|
|
|
position: fixed;
|
|
|
|
|
border-right: 1px solid ${colors.DIVIDER};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.fixed-bottom {
|
|
|
|
|
padding-bottom: 60px;
|
|
|
|
|
.sticky-bottom {
|
|
|
|
|
position: fixed;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
background: ${colors.MAIN};
|
|
|
|
|
border-right: 1px solid ${colors.DIVIDER};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const StickyContainerWrapper = styled.div`
|
|
|
|
|
position: relative;
|
|
|
|
|
top: 0;
|
|
|
|
|
const StickyContainerWrapper = styled.div.attrs({
|
|
|
|
|
style: ({ top, left, paddingBottom }) => ({
|
|
|
|
|
top,
|
|
|
|
|
left,
|
|
|
|
|
paddingBottom,
|
|
|
|
|
}),
|
|
|
|
|
})`
|
|
|
|
|
position: fixed;
|
|
|
|
|
border-right: 1px solid ${colors.DIVIDER};
|
|
|
|
|
width: 320px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export default class StickyContainer extends React.PureComponent<Props> {
|
|
|
|
|
// Class variables.
|
|
|
|
|
currentScrollY: number = 0;
|
|
|
|
|
export default class StickyContainer extends React.PureComponent<Props, State> {
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
|
|
|
|
this.state = {
|
|
|
|
|
prevScrollY: 0,
|
|
|
|
|
asideMinHeight: 0,
|
|
|
|
|
wrapperTopOffset: 0,
|
|
|
|
|
wrapperLeftOffset: 0,
|
|
|
|
|
wrapperBottomPadding: 0,
|
|
|
|
|
footerFixed: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastKnownScrollY: number = 0;
|
|
|
|
|
componentDidMount() {
|
|
|
|
|
window.addEventListener('scroll', this.handleScroll);
|
|
|
|
|
window.addEventListener('resize', this.handleScroll);
|
|
|
|
|
this.update();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
topOffset: number = 0;
|
|
|
|
|
componentDidUpdate(prevProps: Props, newState: State) {
|
|
|
|
|
// recalculate view only if props was changed
|
|
|
|
|
// ignore when state is changed
|
|
|
|
|
if (this.state === newState) raf(this.update);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
firstRender: boolean = false;
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
|
window.removeEventListener('scroll', this.handleScroll);
|
|
|
|
|
window.removeEventListener('resize', this.handleScroll);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
framePending: boolean = false;
|
|
|
|
|
update = () => {
|
|
|
|
|
this.recalculatePosition();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stickToBottom: boolean = false;
|
|
|
|
|
handleScroll = () => raf(this.update);
|
|
|
|
|
|
|
|
|
|
top: number = 0;
|
|
|
|
|
asideRefCallback = (element: ?HTMLElement) => {
|
|
|
|
|
this.aside = element;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
aside: ?HTMLElement;
|
|
|
|
|
wrapperRefCallback = (element: ?HTMLElement) => {
|
|
|
|
|
this.wrapper = element;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wrapper: ?HTMLElement;
|
|
|
|
|
footerRefCallback = (element: ?HTMLElement) => {
|
|
|
|
|
this.footer = element;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
subscribers = [];
|
|
|
|
|
aside: ?HTMLElement;
|
|
|
|
|
|
|
|
|
|
// handleResize = (event: Event) => {
|
|
|
|
|
wrapper: ?HTMLElement;
|
|
|
|
|
|
|
|
|
|
// }
|
|
|
|
|
footer: ?HTMLElement;
|
|
|
|
|
|
|
|
|
|
shouldUpdate = () => {
|
|
|
|
|
const { wrapper, aside }: {wrapper: ?HTMLElement, aside: ?HTMLElement} = this;
|
|
|
|
|
if (!wrapper || !aside) return;
|
|
|
|
|
const bottom: ?HTMLElement = wrapper.querySelector('.sticky-bottom');
|
|
|
|
|
if (!bottom) return;
|
|
|
|
|
recalculatePosition() {
|
|
|
|
|
const { aside, wrapper, footer } = this;
|
|
|
|
|
if (!aside || !wrapper || !footer) return;
|
|
|
|
|
|
|
|
|
|
const viewportHeight: number = getViewportHeight();
|
|
|
|
|
const bottomBounds = bottom.getBoundingClientRect();
|
|
|
|
|
const viewportHeight = getViewportHeight();
|
|
|
|
|
const asideBounds = aside.getBoundingClientRect();
|
|
|
|
|
const wrapperBounds = wrapper.getBoundingClientRect();
|
|
|
|
|
const scrollDirection = this.currentScrollY >= this.lastKnownScrollY ? 'down' : 'up';
|
|
|
|
|
const distanceScrolled = Math.abs(this.currentScrollY - this.lastKnownScrollY);
|
|
|
|
|
|
|
|
|
|
if (asideBounds.top < 0) {
|
|
|
|
|
wrapper.classList.add('fixed');
|
|
|
|
|
let maxTop: number = 1;
|
|
|
|
|
if (wrapperBounds.height > viewportHeight) {
|
|
|
|
|
const bottomOutOfBounds: boolean = (bottomBounds.bottom <= viewportHeight && scrollDirection === 'down');
|
|
|
|
|
const footerBounds = footer.getBoundingClientRect();
|
|
|
|
|
const isHeaderFixed = asideBounds.top < 0;
|
|
|
|
|
const isWrapperBiggerThanViewport = wrapperBounds.height > viewportHeight;
|
|
|
|
|
const state = { ...this.state };
|
|
|
|
|
|
|
|
|
|
const scrollX = getScrollX();
|
|
|
|
|
const scrollY = getScrollY();
|
|
|
|
|
|
|
|
|
|
if (isHeaderFixed) {
|
|
|
|
|
if (isWrapperBiggerThanViewport) {
|
|
|
|
|
const scrollDirection = scrollY >= state.prevScrollY ? 'down' : 'up';
|
|
|
|
|
const topOutOfBounds: boolean = (wrapperBounds.top > 0 && scrollDirection === 'up');
|
|
|
|
|
if (!bottomOutOfBounds && !topOutOfBounds) {
|
|
|
|
|
this.topOffset += scrollDirection === 'down' ? -distanceScrolled : distanceScrolled;
|
|
|
|
|
const bottomOutOfBounds: boolean = (footerBounds.bottom <= viewportHeight && scrollDirection === 'down');
|
|
|
|
|
if (!topOutOfBounds && !bottomOutOfBounds) {
|
|
|
|
|
// neither "top" or "bottom" was reached
|
|
|
|
|
// scroll whole wrapper
|
|
|
|
|
const distanceScrolled = Math.abs(scrollY - state.prevScrollY);
|
|
|
|
|
state.wrapperTopOffset += scrollDirection === 'down' ? -distanceScrolled : distanceScrolled;
|
|
|
|
|
}
|
|
|
|
|
maxTop = viewportHeight - wrapperBounds.height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.topOffset > 0) this.topOffset = 0;
|
|
|
|
|
if (maxTop < 0 && this.topOffset < maxTop) this.topOffset = maxTop;
|
|
|
|
|
wrapper.style.top = `${this.topOffset}px`;
|
|
|
|
|
// make sure that wrapper will not be over scrolled
|
|
|
|
|
if (state.wrapperTopOffset > 0) state.wrapperTopOffset = 0;
|
|
|
|
|
} else {
|
|
|
|
|
wrapper.classList.remove('fixed');
|
|
|
|
|
wrapper.style.top = '0px';
|
|
|
|
|
this.topOffset = 0;
|
|
|
|
|
// update wrapper "top" to be same as "aside" element
|
|
|
|
|
state.wrapperTopOffset = asideBounds.top;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (wrapperBounds.height > viewportHeight) {
|
|
|
|
|
wrapper.classList.remove('fixed-bottom');
|
|
|
|
|
} else if (wrapper.classList.contains('fixed-bottom')) {
|
|
|
|
|
if (bottomBounds.top < wrapperBounds.bottom - bottomBounds.height) {
|
|
|
|
|
wrapper.classList.remove('fixed-bottom');
|
|
|
|
|
}
|
|
|
|
|
} else if (bottomBounds.bottom < viewportHeight) {
|
|
|
|
|
wrapper.classList.add('fixed-bottom');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
aside.style.minHeight = `${wrapperBounds.height}px`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update = () => {
|
|
|
|
|
this.currentScrollY = getScrollY();
|
|
|
|
|
this.shouldUpdate();
|
|
|
|
|
this.framePending = false;
|
|
|
|
|
this.lastKnownScrollY = this.currentScrollY;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
|
window.addEventListener('scroll', this.handleScroll);
|
|
|
|
|
window.addEventListener('resize', this.handleScroll);
|
|
|
|
|
raf(this.update);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
|
|
|
if (this.props.location !== prevProps.location && this.aside) {
|
|
|
|
|
const asideBounds = this.aside.getBoundingClientRect();
|
|
|
|
|
if (asideBounds.top < 0) {
|
|
|
|
|
window.scrollTo(0, getScrollY() + asideBounds.top);
|
|
|
|
|
this.topOffset = 0;
|
|
|
|
|
if (isWrapperBiggerThanViewport) {
|
|
|
|
|
state.footerFixed = false;
|
|
|
|
|
} else if (state.footerFixed) {
|
|
|
|
|
if (footerBounds.top < wrapperBounds.bottom - footerBounds.height) {
|
|
|
|
|
state.footerFixed = false;
|
|
|
|
|
}
|
|
|
|
|
raf(this.update);
|
|
|
|
|
} else if (this.props.deviceSelection !== prevProps.deviceSelection) {
|
|
|
|
|
raf(this.update);
|
|
|
|
|
} else if (!this.firstRender) {
|
|
|
|
|
raf(this.update);
|
|
|
|
|
this.firstRender = true;
|
|
|
|
|
} else if (footerBounds.bottom < viewportHeight) {
|
|
|
|
|
state.footerFixed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
|
window.removeEventListener('scroll', this.handleScroll);
|
|
|
|
|
window.removeEventListener('resize', this.handleScroll);
|
|
|
|
|
}
|
|
|
|
|
state.prevScrollY = scrollY;
|
|
|
|
|
state.asideMinHeight = wrapperBounds.height;
|
|
|
|
|
state.wrapperBottomPadding = state.footerFixed ? footerBounds.height : 0;
|
|
|
|
|
// update wrapper "left" position
|
|
|
|
|
state.wrapperLeftOffset = scrollX > 0 ? -scrollX : asideBounds.left;
|
|
|
|
|
|
|
|
|
|
handleScroll = (/* event: ?Event */) => {
|
|
|
|
|
if (!this.framePending) {
|
|
|
|
|
this.framePending = true;
|
|
|
|
|
raf(this.update);
|
|
|
|
|
}
|
|
|
|
|
this.setState(state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
return (
|
|
|
|
|
<AsideWrapper
|
|
|
|
|
innerRef={(node) => { this.aside = node; }}
|
|
|
|
|
footerFixed={this.state.footerFixed}
|
|
|
|
|
minHeight={this.state.asideMinHeight}
|
|
|
|
|
innerRef={this.asideRefCallback}
|
|
|
|
|
onScroll={this.handleScroll}
|
|
|
|
|
onTouchStart={this.handleScroll}
|
|
|
|
|
onTouchMove={this.handleScroll}
|
|
|
|
|
onTouchEnd={this.handleScroll}
|
|
|
|
|
>
|
|
|
|
|
<StickyContainerWrapper
|
|
|
|
|
innerRef={(node) => { this.wrapper = node; }}
|
|
|
|
|
paddingBottom={this.state.wrapperBottomPadding}
|
|
|
|
|
top={this.state.wrapperTopOffset}
|
|
|
|
|
left={this.state.wrapperLeftOffset}
|
|
|
|
|
innerRef={this.wrapperRefCallback}
|
|
|
|
|
>
|
|
|
|
|
{this.props.children}
|
|
|
|
|
{React.Children.map(this.props.children, (child) => { // eslint-disable-line arrow-body-style
|
|
|
|
|
return child.key === 'sticky-footer' ? React.cloneElement(child, {
|
|
|
|
|
innerRef: this.footerRefCallback,
|
|
|
|
|
position: this.state.footerFixed ? 'fixed' : 'relative',
|
|
|
|
|
}) : child;
|
|
|
|
|
})}
|
|
|
|
|
</StickyContainerWrapper>
|
|
|
|
|
</AsideWrapper>
|
|
|
|
|
);
|
|
|
|
|