Skip to content

Commit 53844f5

Browse files
authored
feat: add styleOptions.stylesRoot prop (#137)
* feat: Add emotionOptions * Add tests and pass through emotionOptions * Add back cache for default emotion objects * Changelog * Wording * Cleanup * Rename to styleOptions * Add more tests * Fix tests * Apply suggestions from code review Self review * Address suggestions * Fix test * Fix docs * Fix table markup
1 parent 0a2d7e8 commit 53844f5

12 files changed

+285
-31
lines changed

.eslintrc.yml

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ parserOptions:
55
ecmaVersion: 12
66
sourceType: module
77

8+
env:
9+
es6: true
10+
811
plugins:
912
- prettier
1013

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Added `styleOptions.stylesRoot` property which allows to specify a container node component styles will be placed into, by [@OEvgeny](https://github.com/OEvgeny) in PR [#137](https://github.com/compulim/react-scroll-to-bottom/pull/137)
13+
1014
## [4.2.0] - 2021-10-14
1115

1216
### Changed

README.md

+13-12
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,19 @@ export default props => (
6262
6363
## Props
6464

65-
| Name | Type | Default | Description |
66-
| ----------------------- | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
67-
| `checkInterval` | `number` | 150 | Recurring interval of stickiness check, in milliseconds (minimum is 17 ms) |
68-
| `className` | `string` | | Set the class name for the root element |
69-
| `debounce` | `number` | `17` | Set the debounce for tracking the `onScroll` event |
70-
| `debug` | `bool` | `NODE_ENV === 'development'` | Show debug information in console |
71-
| `followButtonClassName` | `string` | | Set the class name for the follow button |
72-
| `initialScrollBehavior` | `string` | `smooth` | Set the initial scroll behavior, either `"auto"` (discrete scrolling) or `"smooth"` |
73-
| `mode` | `string` | `"bottom"` | Set it to `"bottom"` for scroll-to-bottom, `"top"` for scroll-to-top |
74-
| `nonce` | `string` | | Set the nonce for [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) |
75-
| `scroller` | `function` | `() => Infinity` | A function to determine how far should scroll when scroll is needed |
76-
| `scrollViewClassName` | `string` | | Set the class name for the container element that house all `props.children` |
65+
| Name | Type | Default | Description |
66+
| ------------------------- | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
67+
| `checkInterval` | `number` | 150 | Recurring interval of stickiness check, in milliseconds (minimum is 17 ms) |
68+
| `className` | `string` | | Set the class name for the root element |
69+
| `debounce` | `number` | `17` | Set the debounce for tracking the `onScroll` event |
70+
| `debug` | `bool` | `NODE_ENV === 'development'` | Show debug information in console |
71+
| `followButtonClassName` | `string` | | Set the class name for the follow button |
72+
| `initialScrollBehavior` | `string` | `smooth` | Set the initial scroll behavior, either `"auto"` (discrete scrolling) or `"smooth"` |
73+
| `mode` | `string` | `"bottom"` | Set it to `"bottom"` for scroll-to-bottom, `"top"` for scroll-to-top |
74+
| `nonce` | `string` | | Set the nonce for [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) |
75+
| `scroller` | `function` | `() => Infinity` | A function to determine how far should scroll when scroll is needed |
76+
| `scrollViewClassName` | `string` | | Set the class name for the container element that house all `props.children` |
77+
| `styleOptions.stylesRoot` | `Node` | `undefined` | Set the container node for component styles to be placed into. When set to `undefined`, will use `document.head` |
7778

7879
## Hooks
7980

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title></title>
5+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
6+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
7+
<script src="/react-scroll-to-bottom.development.js"></script>
8+
<script src="/test-harness.js"></script>
9+
<script src="/assets/page-object-model.js"></script>
10+
</head>
11+
<body>
12+
<div id="styles-a"></div>
13+
<div id="styles-b"></div>
14+
<div id="app"></div>
15+
</body>
16+
<script type="text/babel" data-presets="react">
17+
'use strict';
18+
19+
run(async function () {
20+
let updateStylesRoot
21+
const App = () => {
22+
const [stylesRoot, setStylesRoot] = React.useState(document.getElementById('styles-a'));
23+
24+
let resolve, promise;
25+
promise = new Promise(r => resolve = r);
26+
27+
React.useEffect(() => () => requestIdleCallback(resolve));
28+
updateStylesRoot = root => {
29+
setStylesRoot(root);
30+
return promise;
31+
}
32+
33+
return (
34+
<ReactScrollToBottom.default
35+
className="react-scroll-to-bottom"
36+
followButtonClassName="follow"
37+
scrollViewClassName="scrollable"
38+
styleOptions={{ stylesRoot }}
39+
>
40+
{pageObjects.paragraphs.map(paragraph => (
41+
<p key={paragraph}>{paragraph}</p>
42+
))}
43+
</ReactScrollToBottom.default>
44+
);
45+
}
46+
await new Promise(resolve =>
47+
ReactDOM.render(
48+
<App />,
49+
document.getElementById('app'),
50+
resolve
51+
)
52+
);
53+
54+
await pageObjects.scrollStabilizedAtBottom();
55+
56+
expect(document.getElementsByClassName('follow')[0]).toBeFalsy();
57+
58+
await pageObjects.mouseWheel(-100);
59+
60+
await pageObjects.scrollStabilized();
61+
62+
expect(document.getElementsByClassName('follow')[0]).toBeTruthy();
63+
expect(document.getElementById('styles-a').childElementCount).toBeGreaterThan(0);
64+
expect(document.getElementById('styles-b').childElementCount).toBe(0);
65+
66+
await updateStylesRoot(document.getElementById('styles-b'));
67+
68+
expect(document.getElementById('styles-a').childElementCount).toBe(0);
69+
expect(document.getElementById('styles-b').childElementCount).toBeGreaterThan(0);
70+
});
71+
</script>
72+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/** @jest-environment ./packages/test-harness/JestEnvironment */
2+
3+
test('should-respect-container-change.html', () => runHTML('should-respect-container-change.html'));
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title></title>
5+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
6+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
7+
<script src="/react-scroll-to-bottom.development.js"></script>
8+
<script src="/test-harness.js"></script>
9+
<script src="/assets/page-object-model.js"></script>
10+
</head>
11+
<body>
12+
<div id="styles-a"></div>
13+
<div id="styles-b"></div>
14+
<div id="app"></div>
15+
</body>
16+
<script type="text/babel" data-presets="react">
17+
'use strict';
18+
19+
run(async function () {
20+
let updateNonce
21+
const App = () => {
22+
const [nonce, setNonce] = React.useState('old-nonce');
23+
24+
let resolve, promise;
25+
promise = new Promise(r => resolve = r);
26+
27+
React.useEffect(() => () => requestIdleCallback(resolve));
28+
updateNonce = root => {
29+
setNonce(root);
30+
return promise;
31+
}
32+
33+
return (
34+
<ReactScrollToBottom.default
35+
className="react-scroll-to-bottom"
36+
followButtonClassName="follow"
37+
nonce={nonce}
38+
scrollViewClassName="scrollable"
39+
>
40+
{pageObjects.paragraphs.map(paragraph => (
41+
<p key={paragraph}>{paragraph}</p>
42+
))}
43+
</ReactScrollToBottom.default>
44+
);
45+
}
46+
await new Promise(resolve =>
47+
ReactDOM.render(
48+
<App />,
49+
document.getElementById('app'),
50+
resolve
51+
)
52+
);
53+
54+
await pageObjects.scrollStabilizedAtBottom();
55+
56+
expect(document.getElementsByClassName('follow')[0]).toBeFalsy();
57+
58+
await pageObjects.mouseWheel(-100);
59+
60+
await pageObjects.scrollStabilized();
61+
62+
const emotionStyle = document.querySelector('[data-emotion]')
63+
const stylesCount = document.querySelectorAll('[data-emotion]').length;
64+
65+
expect(document.getElementsByClassName('follow')[0]).toBeTruthy();
66+
expect(emotionStyle).toBeDefined();
67+
68+
await updateNonce('new-nonce');
69+
70+
expect(document.querySelector('[data-emotion]')).not.toEqual(emotionStyle);
71+
expect(emotionStyle.parentElement).toBe(null);
72+
expect(document.querySelectorAll('[data-emotion]').length).toBe(stylesCount);
73+
});
74+
</script>
75+
</html>
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/** @jest-environment ./packages/test-harness/JestEnvironment */
2+
3+
test('should-respect-nonce-change.html', () => runHTML('should-respect-nonce-change.html'));

__tests__/should-respect-options.html

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title></title>
5+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
6+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
7+
<script src="/react-scroll-to-bottom.development.js"></script>
8+
<script src="/test-harness.js"></script>
9+
<script src="/assets/page-object-model.js"></script>
10+
</head>
11+
<body>
12+
<div id="styles"></div>
13+
<div id="app"></div>
14+
</body>
15+
<script type="text/babel" data-presets="react">
16+
'use strict';
17+
18+
const styleOptions = {
19+
stylesRoot: document.getElementById('styles')
20+
};
21+
22+
run(async function () {
23+
await new Promise(resolve =>
24+
ReactDOM.render(
25+
<ReactScrollToBottom.default
26+
className="react-scroll-to-bottom"
27+
followButtonClassName="follow"
28+
scrollViewClassName="scrollable"
29+
styleOptions={styleOptions}
30+
>
31+
{pageObjects.paragraphs.map(paragraph => (
32+
<p key={paragraph}>{paragraph}</p>
33+
))}
34+
</ReactScrollToBottom.default>,
35+
document.getElementById('app'),
36+
resolve
37+
)
38+
);
39+
40+
await pageObjects.scrollStabilizedAtBottom();
41+
42+
expect(document.getElementsByClassName('follow')[0]).toBeFalsy();
43+
44+
await pageObjects.mouseWheel(-100);
45+
46+
await pageObjects.scrollStabilized();
47+
48+
expect(document.getElementsByClassName('follow')[0]).toBeTruthy();
49+
expect(document.getElementById('styles').childElementCount).toBeGreaterThan(0);
50+
});
51+
</script>
52+
</html>

__tests__/should-respect-options.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/** @jest-environment ./packages/test-harness/JestEnvironment */
2+
3+
test('should-respect-options.html', () => runHTML('should-respect-options.html'));

packages/component/src/BasicScrollToBottom.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ const BasicScrollToBottom = ({
4747
mode,
4848
nonce,
4949
scroller,
50-
scrollViewClassName
50+
scrollViewClassName,
51+
styleOptions
5152
}) => (
5253
<Composer
5354
checkInterval={checkInterval}
@@ -57,6 +58,7 @@ const BasicScrollToBottom = ({
5758
mode={mode}
5859
nonce={nonce}
5960
scroller={scroller}
61+
styleOptions={styleOptions}
6062
>
6163
<BasicScrollToBottomCore
6264
className={className}
@@ -79,7 +81,8 @@ BasicScrollToBottom.defaultProps = {
7981
mode: undefined,
8082
nonce: undefined,
8183
scroller: undefined,
82-
scrollViewClassName: undefined
84+
scrollViewClassName: undefined,
85+
styleOptions: undefined
8386
};
8487

8588
BasicScrollToBottom.propTypes = {
@@ -93,7 +96,8 @@ BasicScrollToBottom.propTypes = {
9396
mode: PropTypes.oneOf(['bottom', 'top']),
9497
nonce: PropTypes.string,
9598
scroller: PropTypes.func,
96-
scrollViewClassName: PropTypes.string
99+
scrollViewClassName: PropTypes.string,
100+
styleOptions: PropTypes.any
97101
};
98102

99103
export default BasicScrollToBottom;

packages/component/src/ScrollToBottom/Composer.js

+9-16
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import createEmotion from '@emotion/css/create-instance';
21
import PropTypes from 'prop-types';
32
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
43

5-
import createCSSKey from '../createCSSKey';
64
import createDebug from '../utils/debug';
75
import EventSpy from '../EventSpy';
86
import FunctionContext from './FunctionContext';
@@ -12,6 +10,7 @@ import State1Context from './State1Context';
1210
import State2Context from './State2Context';
1311
import StateContext from './StateContext';
1412
import styleConsole from '../utils/styleConsole';
13+
import useEmotion from '../hooks/internal/useEmotion';
1514
import useStateRef from '../hooks/internal/useStateRef';
1615

1716
const DEFAULT_SCROLLER = () => Infinity;
@@ -21,10 +20,6 @@ const MODE_TOP = 'top';
2120
const NEAR_END_THRESHOLD = 1;
2221
const SCROLL_DECISION_DURATION = 34; // 2 frames
2322

24-
// We pool the emotion object by nonce.
25-
// This is to make sure we don't generate too many unneeded <style> tags.
26-
const emotionPool = {};
27-
2823
function setImmediateInterval(fn, ms) {
2924
fn();
3025

@@ -58,7 +53,8 @@ const Composer = ({
5853
initialScrollBehavior,
5954
mode,
6055
nonce,
61-
scroller
56+
scroller,
57+
styleOptions
6258
}) => {
6359
const debug = useMemo(() => createDebug(`<ScrollToBottom>`, { force: debugFromProp }), [debugFromProp]);
6460

@@ -501,13 +497,8 @@ const Composer = ({
501497
}
502498
}, [animateToRef, checkInterval, debug, mode, scrollToSticky, setSticky, stickyRef, target, targetRef]);
503499

504-
const styleToClassName = useMemo(() => {
505-
const emotion =
506-
emotionPool[nonce] ||
507-
(emotionPool[nonce] = createEmotion({ key: 'react-scroll-to-bottom--css-' + createCSSKey(), nonce }));
508-
509-
return style => emotion.css(style) + '';
510-
}, [nonce]);
500+
const emotion = useEmotion(nonce, styleOptions?.stylesRoot);
501+
const styleToClassName = useCallback(style => emotion.css(style) + '', [emotion]);
511502

512503
const internalContext = useMemo(
513504
() => ({
@@ -626,7 +617,8 @@ Composer.defaultProps = {
626617
initialScrollBehavior: 'smooth',
627618
mode: undefined,
628619
nonce: undefined,
629-
scroller: DEFAULT_SCROLLER
620+
scroller: DEFAULT_SCROLLER,
621+
styleOptions: undefined
630622
};
631623

632624
Composer.propTypes = {
@@ -637,7 +629,8 @@ Composer.propTypes = {
637629
initialScrollBehavior: PropTypes.oneOf(['auto', 'smooth']),
638630
mode: PropTypes.oneOf(['bottom', 'top']),
639631
nonce: PropTypes.string,
640-
scroller: PropTypes.func
632+
scroller: PropTypes.func,
633+
styleOptions: PropTypes.any
641634
};
642635

643636
export default Composer;

0 commit comments

Comments
 (0)