Skip to content

Commit

Permalink
enhance BackToTop to work in arbitrary scrollable containers [#150929…
Browse files Browse the repository at this point in the history
…993]

Signed-off-by: Ming Xiao <mxiao@pivotal.io>
  • Loading branch information
Jonathan Berney authored and Ming Xiao committed Sep 12, 2017
1 parent 2be1f33 commit 28b3943
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 95 deletions.
194 changes: 128 additions & 66 deletions spec/pivotal-ui-react/back-to-top/back-to-top_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import '../spec_helper';
import {BackToTop} from '../../../src/react/back-to-top';
import ScrollTop from '../../../src/react/back-to-top/scroll-top';

describe('BackToTop', function() {
let originalGetScrollTop, originalSetScrollTop;
describe('BackToTop', () => {
let scrollTop;

function triggerScroll() {
Expand All @@ -12,89 +11,152 @@ describe('BackToTop', function() {
window.dispatchEvent(event);
}

beforeEach(function() {
beforeEach(() => {
scrollTop = 0;
originalGetScrollTop = ScrollTop.getScrollTop;
originalSetScrollTop = ScrollTop.setScrollTop;
ScrollTop.getScrollTop = () => scrollTop || 0;
ScrollTop.setScrollTop = (value) => scrollTop = value;
spyOn(ScrollTop, 'getScrollTop').and.callFake(() => scrollTop || 0);
spyOn(ScrollTop, 'setScrollTop').and.callFake(value => scrollTop = value);
});

afterEach(function() {
ScrollTop.getScrollTop = originalGetScrollTop;
ScrollTop.setScrollTop = originalSetScrollTop;
});

beforeEach(function(done) {
ReactDOM.render(<BackToTop className="foo" id="bar" style={{fontSize: '200px'}}/>, root);
describe('without scrollableId', () => {
beforeEach(done => {
ReactDOM.render(<BackToTop className="foo" id="bar" style={{fontSize: '200px'}}/>, root);

jasmine.clock().uninstall();
setTimeout(function() {
jasmine.clock().install();
ScrollTop.setScrollTop(500);
triggerScroll();
done();
}, 0);
});

it('passes down the className, id, and style properties', () => {
expect('.back-to-top').toHaveClass('foo');
expect('.back-to-top').toHaveProp('id', 'bar');
expect('.back-to-top').toHaveCss({'font-size': '200px'});
});

it('renders a back to top link that is visible', function() {
expect('.back-to-top').toExist();
});

it('renders a arrow upward icon', () => {
expect('svg.icon-arrow_upward').toExist();
});
jasmine.clock().uninstall();
setTimeout(() => {
jasmine.clock().install();
ScrollTop.setScrollTop(500);
triggerScroll();
done();
}, 0);
});

it('fades in the button', function() {
expect('.back-to-top').toHaveCss({opacity: '0'});
MockNow.tick(BackToTop.FADE_DURATION / 2);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '0.5'});
MockNow.tick(BackToTop.FADE_DURATION / 2);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '1'});
});
it('passes down the className, id, and style properties', () => {
expect('.back-to-top').toHaveClass('foo');
expect('.back-to-top').toHaveProp('id', 'bar');
expect('.back-to-top').toHaveCss({'font-size': '200px'});
});

describe('when the scroll top is less than 400', function() {
beforeEach(function() {
MockNow.tick(BackToTop.FADE_DURATION);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '1'});
it('renders a back to top link that is visible', () => {
expect('.back-to-top').toExist();
});

ScrollTop.setScrollTop(0);
triggerScroll();
it('renders a arrow upward icon', () => {
expect('svg.icon-arrow_upward').toExist();
});

it('fades out the button', function() {
expect('.back-to-top').toHaveCss({opacity: '1'});
it('fades in the button', () => {
expect('.back-to-top').toHaveCss({opacity: '0'});
MockNow.tick(BackToTop.FADE_DURATION / 2);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '0.5'});
MockNow.tick(BackToTop.FADE_DURATION / 2);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '0'});
expect('.back-to-top').toHaveCss({opacity: '1'});
});

describe('when the scroll top is less than 400', () => {
beforeEach(function () {
MockNow.tick(BackToTop.FADE_DURATION);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '1'});

ScrollTop.setScrollTop(0);
triggerScroll();
});

it('fades out the button', () => {
expect('.back-to-top').toHaveCss({opacity: '1'});
MockNow.tick(BackToTop.FADE_DURATION / 2);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '0.5'});
MockNow.tick(BackToTop.FADE_DURATION / 2);
MockRaf.next();
expect('.back-to-top').toHaveCss({opacity: '0'});
});
});

describe('when the back to top link is clicked', () => {
let element;

beforeEach(() => {
const isFirefox = () => navigator.userAgent.toLowerCase().indexOf('firefox') !== -1;
element = isFirefox() ? document.documentElement : document.body;
$('.back-to-top').simulate('click');
});

it('animates the body scroll to the top', () => {
expect(ScrollTop.getScrollTop()).toEqual(500);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.getScrollTop()).toEqual(62.5);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.getScrollTop()).toEqual(0);
});

it('calls getScrollTop', () => {
expect(ScrollTop.getScrollTop).toHaveBeenCalledWith(element);
});

it('calls setScrollTop', () => {
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(500, element);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(62.5, element);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(0, element);
});
});
});

describe('when the back to top link is clicked', function() {
beforeEach(function() {
$('.back-to-top').simulate('click');
describe('with a scrollableId', () => {
let scrollableId;

beforeEach(done => {
scrollableId = 'scrollable';
ReactDOM.render(<div id={scrollableId} style={{height: '100px', maxHeight: '100px', overflowY: 'scroll'}}>
<div {...{height: '500px'}}/>
<BackToTop {...{className: 'foo', id: 'bar', style: {fontSize: '500px'}, scrollableId}}/>
</div>, root);

jasmine.clock().uninstall();
setTimeout(() => {
jasmine.clock().install();
ScrollTop.setScrollTop(100, scrollableId);
triggerScroll();
done();
}, 0);
});

it('animates the body scroll to the top', function() {
expect(ScrollTop.getScrollTop()).toEqual(500);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.getScrollTop()).toEqual(62.5);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.getScrollTop()).toEqual(0);
describe('when the back to top link is clicked', () => {
beforeEach(() => {
$('.back-to-top').simulate('click');
});

it('calls getScrollTop', () => {
expect(ScrollTop.getScrollTop).toHaveBeenCalledWith(window.scrollable);
});

it('calls setScrollTop', () => {
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(100, window.scrollable);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(12.5, window.scrollable);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(0, window.scrollable);
});

it('animates the body scroll to the top', () => {
expect(scrollTop).toEqual(100);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(scrollTop).toEqual(12.5);
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
MockRaf.next();
expect(scrollTop).toEqual(0);
});
});
});
});
56 changes: 36 additions & 20 deletions src/react/back-to-top/back-to-top.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,79 @@ import {Icon} from '../iconography';
import React from 'react';
import PropTypes from 'prop-types';
import throttle from 'lodash.throttle';
import {getScrollTop, setScrollTop} from './scroll-top';
import ScrollTop from './scroll-top';
import {mergeProps} from '../helpers';
import {default as mixin} from '../mixins';
import Animation from '../mixins/mixins/animation_mixin';

export class BackToTop extends mixin(React.PureComponent).with(Animation) {
constructor(props, context) {
super(props, context);
this.state = {visible: false};
}
const isFirefox = () => navigator.userAgent.toLowerCase().indexOf('firefox') !== -1;

function getElement(id) {
if (id) return document.getElementById(id);
if (isFirefox()) return document.documentElement;
return document.body;
}

const privates = new WeakMap();

export class BackToTop extends mixin(React.PureComponent).with(Animation) {
static propTypes = {
alwaysVisible: PropTypes.bool
alwaysVisible: PropTypes.bool,
scrollableId: PropTypes.string
};

static FADE_DURATION = 300;
static VISIBILITY_HEIGHT = 400;
static SCROLL_DURATION = 200;

constructor(props, context) {
super(props, context);
this.state = {visible: false};
}

componentDidMount() {
require('../../css/back-to-top');
this.updateScroll = throttle(this.updateScroll, 100);
window.addEventListener('scroll', this.updateScroll);
const {scrollableId} = this.props;
const element = getElement(scrollableId);
privates.set(this, {element})
}

componentWillUnmount() {
window.removeEventListener('scroll', this.updateScroll);
}

updateScroll = () => this.setState({visible: getScrollTop() > BackToTop.VISIBILITY_HEIGHT});
updateScroll = () => {
const {element} = privates.get(this);
this.setState({visible: ScrollTop.getScrollTop(element) > BackToTop.VISIBILITY_HEIGHT});
};

scrollToTop = () => {
const key = `pui-back-to-top-${Date.now()}`;
this.animate(key, 0, BackToTop.SCROLL_DURATION, {
startValue: getScrollTop(),
easing: 'easeOutCubic'
});
this.setState({key});
};

render() {
const {alwaysVisible, ...others} = this.props;
const {alwaysVisible, scrollableId, ...others} = this.props;
const {visible: visibleState} = this.state;
const {element} = privates.get(this) || {};
const visible = alwaysVisible || visibleState;
const props = mergeProps(others, {
className: 'back-to-top',
style: {display: 'inline', opacity: this.animate('opacity', visible ? 1 : 0, BackToTop.FADE_DURATION)}
});

const {key} = this.state;
const scrollTarget = this.animate(key, 0, BackToTop.SCROLL_DURATION, {
startValue: getScrollTop(),
easing: 'easeOutCubic'
});

key && setScrollTop(scrollTarget);
scrollTarget || setTimeout(() => this.setState({key: null}), 10);
if (key) {
const startValue = ScrollTop.getScrollTop(element);
const scrollTarget = this.animate(key, 0, BackToTop.SCROLL_DURATION, {
startValue,
easing: 'easeOutCubic'
});
ScrollTop.setScrollTop(scrollTarget, element);
if (!scrollTarget) setTimeout(() => this.setState({key: null}), 10);
}

return (<a {...props} onClick={this.scrollToTop} aria-label="Back to top">
<Icon style={{strokeWidth: 100}} src="arrow_upward"/>
Expand Down
11 changes: 2 additions & 9 deletions src/react/back-to-top/scroll-top.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
const isFirefox = () => navigator.userAgent.toLowerCase().indexOf('firefox') !== -1;

const getScrollTop = () => isFirefox() ? document.documentElement.scrollTop : document.body.scrollTop;

const setScrollTop = value => {
document.body.scrollTop = value;
document.documentElement.scrollTop = value;
};

const getScrollTop = ({scrollTop}) => scrollTop;
const setScrollTop = (value, element) => element.scrollTop = value;
export default {getScrollTop, setScrollTop};

0 comments on commit 28b3943

Please sign in to comment.