Skip to content

Commit 28b3943

Browse files
Jonathan BerneyMing Xiao
authored andcommitted
enhance BackToTop to work in arbitrary scrollable containers [#150929993]
Signed-off-by: Ming Xiao <mxiao@pivotal.io>
1 parent 2be1f33 commit 28b3943

File tree

3 files changed

+166
-95
lines changed

3 files changed

+166
-95
lines changed

spec/pivotal-ui-react/back-to-top/back-to-top_spec.js

Lines changed: 128 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import '../spec_helper';
22
import {BackToTop} from '../../../src/react/back-to-top';
33
import ScrollTop from '../../../src/react/back-to-top/scroll-top';
44

5-
describe('BackToTop', function() {
6-
let originalGetScrollTop, originalSetScrollTop;
5+
describe('BackToTop', () => {
76
let scrollTop;
87

98
function triggerScroll() {
@@ -12,89 +11,152 @@ describe('BackToTop', function() {
1211
window.dispatchEvent(event);
1312
}
1413

15-
beforeEach(function() {
14+
beforeEach(() => {
1615
scrollTop = 0;
17-
originalGetScrollTop = ScrollTop.getScrollTop;
18-
originalSetScrollTop = ScrollTop.setScrollTop;
19-
ScrollTop.getScrollTop = () => scrollTop || 0;
20-
ScrollTop.setScrollTop = (value) => scrollTop = value;
16+
spyOn(ScrollTop, 'getScrollTop').and.callFake(() => scrollTop || 0);
17+
spyOn(ScrollTop, 'setScrollTop').and.callFake(value => scrollTop = value);
2118
});
2219

23-
afterEach(function() {
24-
ScrollTop.getScrollTop = originalGetScrollTop;
25-
ScrollTop.setScrollTop = originalSetScrollTop;
26-
});
27-
28-
beforeEach(function(done) {
29-
ReactDOM.render(<BackToTop className="foo" id="bar" style={{fontSize: '200px'}}/>, root);
20+
describe('without scrollableId', () => {
21+
beforeEach(done => {
22+
ReactDOM.render(<BackToTop className="foo" id="bar" style={{fontSize: '200px'}}/>, root);
3023

31-
jasmine.clock().uninstall();
32-
setTimeout(function() {
33-
jasmine.clock().install();
34-
ScrollTop.setScrollTop(500);
35-
triggerScroll();
36-
done();
37-
}, 0);
38-
});
39-
40-
it('passes down the className, id, and style properties', () => {
41-
expect('.back-to-top').toHaveClass('foo');
42-
expect('.back-to-top').toHaveProp('id', 'bar');
43-
expect('.back-to-top').toHaveCss({'font-size': '200px'});
44-
});
45-
46-
it('renders a back to top link that is visible', function() {
47-
expect('.back-to-top').toExist();
48-
});
49-
50-
it('renders a arrow upward icon', () => {
51-
expect('svg.icon-arrow_upward').toExist();
52-
});
24+
jasmine.clock().uninstall();
25+
setTimeout(() => {
26+
jasmine.clock().install();
27+
ScrollTop.setScrollTop(500);
28+
triggerScroll();
29+
done();
30+
}, 0);
31+
});
5332

54-
it('fades in the button', function() {
55-
expect('.back-to-top').toHaveCss({opacity: '0'});
56-
MockNow.tick(BackToTop.FADE_DURATION / 2);
57-
MockRaf.next();
58-
expect('.back-to-top').toHaveCss({opacity: '0.5'});
59-
MockNow.tick(BackToTop.FADE_DURATION / 2);
60-
MockRaf.next();
61-
expect('.back-to-top').toHaveCss({opacity: '1'});
62-
});
33+
it('passes down the className, id, and style properties', () => {
34+
expect('.back-to-top').toHaveClass('foo');
35+
expect('.back-to-top').toHaveProp('id', 'bar');
36+
expect('.back-to-top').toHaveCss({'font-size': '200px'});
37+
});
6338

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

70-
ScrollTop.setScrollTop(0);
71-
triggerScroll();
43+
it('renders a arrow upward icon', () => {
44+
expect('svg.icon-arrow_upward').toExist();
7245
});
7346

74-
it('fades out the button', function() {
75-
expect('.back-to-top').toHaveCss({opacity: '1'});
47+
it('fades in the button', () => {
48+
expect('.back-to-top').toHaveCss({opacity: '0'});
7649
MockNow.tick(BackToTop.FADE_DURATION / 2);
7750
MockRaf.next();
7851
expect('.back-to-top').toHaveCss({opacity: '0.5'});
7952
MockNow.tick(BackToTop.FADE_DURATION / 2);
8053
MockRaf.next();
81-
expect('.back-to-top').toHaveCss({opacity: '0'});
54+
expect('.back-to-top').toHaveCss({opacity: '1'});
55+
});
56+
57+
describe('when the scroll top is less than 400', () => {
58+
beforeEach(function () {
59+
MockNow.tick(BackToTop.FADE_DURATION);
60+
MockRaf.next();
61+
expect('.back-to-top').toHaveCss({opacity: '1'});
62+
63+
ScrollTop.setScrollTop(0);
64+
triggerScroll();
65+
});
66+
67+
it('fades out the button', () => {
68+
expect('.back-to-top').toHaveCss({opacity: '1'});
69+
MockNow.tick(BackToTop.FADE_DURATION / 2);
70+
MockRaf.next();
71+
expect('.back-to-top').toHaveCss({opacity: '0.5'});
72+
MockNow.tick(BackToTop.FADE_DURATION / 2);
73+
MockRaf.next();
74+
expect('.back-to-top').toHaveCss({opacity: '0'});
75+
});
76+
});
77+
78+
describe('when the back to top link is clicked', () => {
79+
let element;
80+
81+
beforeEach(() => {
82+
const isFirefox = () => navigator.userAgent.toLowerCase().indexOf('firefox') !== -1;
83+
element = isFirefox() ? document.documentElement : document.body;
84+
$('.back-to-top').simulate('click');
85+
});
86+
87+
it('animates the body scroll to the top', () => {
88+
expect(ScrollTop.getScrollTop()).toEqual(500);
89+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
90+
MockRaf.next();
91+
expect(ScrollTop.getScrollTop()).toEqual(62.5);
92+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
93+
MockRaf.next();
94+
expect(ScrollTop.getScrollTop()).toEqual(0);
95+
});
96+
97+
it('calls getScrollTop', () => {
98+
expect(ScrollTop.getScrollTop).toHaveBeenCalledWith(element);
99+
});
100+
101+
it('calls setScrollTop', () => {
102+
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(500, element);
103+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
104+
MockRaf.next();
105+
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(62.5, element);
106+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
107+
MockRaf.next();
108+
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(0, element);
109+
});
82110
});
83111
});
84112

85-
describe('when the back to top link is clicked', function() {
86-
beforeEach(function() {
87-
$('.back-to-top').simulate('click');
113+
describe('with a scrollableId', () => {
114+
let scrollableId;
115+
116+
beforeEach(done => {
117+
scrollableId = 'scrollable';
118+
ReactDOM.render(<div id={scrollableId} style={{height: '100px', maxHeight: '100px', overflowY: 'scroll'}}>
119+
<div {...{height: '500px'}}/>
120+
<BackToTop {...{className: 'foo', id: 'bar', style: {fontSize: '500px'}, scrollableId}}/>
121+
</div>, root);
122+
123+
jasmine.clock().uninstall();
124+
setTimeout(() => {
125+
jasmine.clock().install();
126+
ScrollTop.setScrollTop(100, scrollableId);
127+
triggerScroll();
128+
done();
129+
}, 0);
88130
});
89131

90-
it('animates the body scroll to the top', function() {
91-
expect(ScrollTop.getScrollTop()).toEqual(500);
92-
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
93-
MockRaf.next();
94-
expect(ScrollTop.getScrollTop()).toEqual(62.5);
95-
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
96-
MockRaf.next();
97-
expect(ScrollTop.getScrollTop()).toEqual(0);
132+
describe('when the back to top link is clicked', () => {
133+
beforeEach(() => {
134+
$('.back-to-top').simulate('click');
135+
});
136+
137+
it('calls getScrollTop', () => {
138+
expect(ScrollTop.getScrollTop).toHaveBeenCalledWith(window.scrollable);
139+
});
140+
141+
it('calls setScrollTop', () => {
142+
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(100, window.scrollable);
143+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
144+
MockRaf.next();
145+
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(12.5, window.scrollable);
146+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
147+
MockRaf.next();
148+
expect(ScrollTop.setScrollTop).toHaveBeenCalledWith(0, window.scrollable);
149+
});
150+
151+
it('animates the body scroll to the top', () => {
152+
expect(scrollTop).toEqual(100);
153+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
154+
MockRaf.next();
155+
expect(scrollTop).toEqual(12.5);
156+
MockNow.tick(BackToTop.SCROLL_DURATION / 2);
157+
MockRaf.next();
158+
expect(scrollTop).toEqual(0);
159+
});
98160
});
99161
});
100162
});

src/react/back-to-top/back-to-top.js

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,79 @@ import {Icon} from '../iconography';
22
import React from 'react';
33
import PropTypes from 'prop-types';
44
import throttle from 'lodash.throttle';
5-
import {getScrollTop, setScrollTop} from './scroll-top';
5+
import ScrollTop from './scroll-top';
66
import {mergeProps} from '../helpers';
77
import {default as mixin} from '../mixins';
88
import Animation from '../mixins/mixins/animation_mixin';
99

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

12+
function getElement(id) {
13+
if (id) return document.getElementById(id);
14+
if (isFirefox()) return document.documentElement;
15+
return document.body;
16+
}
17+
18+
const privates = new WeakMap();
19+
20+
export class BackToTop extends mixin(React.PureComponent).with(Animation) {
1621
static propTypes = {
17-
alwaysVisible: PropTypes.bool
22+
alwaysVisible: PropTypes.bool,
23+
scrollableId: PropTypes.string
1824
};
1925

2026
static FADE_DURATION = 300;
2127
static VISIBILITY_HEIGHT = 400;
2228
static SCROLL_DURATION = 200;
2329

30+
constructor(props, context) {
31+
super(props, context);
32+
this.state = {visible: false};
33+
}
34+
2435
componentDidMount() {
2536
require('../../css/back-to-top');
2637
this.updateScroll = throttle(this.updateScroll, 100);
2738
window.addEventListener('scroll', this.updateScroll);
39+
const {scrollableId} = this.props;
40+
const element = getElement(scrollableId);
41+
privates.set(this, {element})
2842
}
2943

3044
componentWillUnmount() {
3145
window.removeEventListener('scroll', this.updateScroll);
3246
}
3347

34-
updateScroll = () => this.setState({visible: getScrollTop() > BackToTop.VISIBILITY_HEIGHT});
48+
updateScroll = () => {
49+
const {element} = privates.get(this);
50+
this.setState({visible: ScrollTop.getScrollTop(element) > BackToTop.VISIBILITY_HEIGHT});
51+
};
3552

3653
scrollToTop = () => {
3754
const key = `pui-back-to-top-${Date.now()}`;
38-
this.animate(key, 0, BackToTop.SCROLL_DURATION, {
39-
startValue: getScrollTop(),
40-
easing: 'easeOutCubic'
41-
});
4255
this.setState({key});
4356
};
4457

4558
render() {
46-
const {alwaysVisible, ...others} = this.props;
59+
const {alwaysVisible, scrollableId, ...others} = this.props;
4760
const {visible: visibleState} = this.state;
61+
const {element} = privates.get(this) || {};
4862
const visible = alwaysVisible || visibleState;
4963
const props = mergeProps(others, {
5064
className: 'back-to-top',
5165
style: {display: 'inline', opacity: this.animate('opacity', visible ? 1 : 0, BackToTop.FADE_DURATION)}
5266
});
5367

5468
const {key} = this.state;
55-
const scrollTarget = this.animate(key, 0, BackToTop.SCROLL_DURATION, {
56-
startValue: getScrollTop(),
57-
easing: 'easeOutCubic'
58-
});
59-
60-
key && setScrollTop(scrollTarget);
61-
scrollTarget || setTimeout(() => this.setState({key: null}), 10);
69+
if (key) {
70+
const startValue = ScrollTop.getScrollTop(element);
71+
const scrollTarget = this.animate(key, 0, BackToTop.SCROLL_DURATION, {
72+
startValue,
73+
easing: 'easeOutCubic'
74+
});
75+
ScrollTop.setScrollTop(scrollTarget, element);
76+
if (!scrollTarget) setTimeout(() => this.setState({key: null}), 10);
77+
}
6278

6379
return (<a {...props} onClick={this.scrollToTop} aria-label="Back to top">
6480
<Icon style={{strokeWidth: 100}} src="arrow_upward"/>

src/react/back-to-top/scroll-top.js

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
const isFirefox = () => navigator.userAgent.toLowerCase().indexOf('firefox') !== -1;
2-
3-
const getScrollTop = () => isFirefox() ? document.documentElement.scrollTop : document.body.scrollTop;
4-
5-
const setScrollTop = value => {
6-
document.body.scrollTop = value;
7-
document.documentElement.scrollTop = value;
8-
};
9-
1+
const getScrollTop = ({scrollTop}) => scrollTop;
2+
const setScrollTop = (value, element) => element.scrollTop = value;
103
export default {getScrollTop, setScrollTop};

0 commit comments

Comments
 (0)