Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add styleOptions.stylesRoot prop #137

Merged
merged 14 commits into from
May 30, 2024
3 changes: 3 additions & 0 deletions .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ parserOptions:
ecmaVersion: 12
sourceType: module

env:
es6: true

plugins:
- prettier

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Changed

- 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)

## [4.2.0] - 2021-10-14

### Changed
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,19 @@ export default props => (

## Props

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

## Hooks

Expand Down
72 changes: 72 additions & 0 deletions __tests__/should-respect-container-change.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="/react-scroll-to-bottom.development.js"></script>
<script src="/test-harness.js"></script>
<script src="/assets/page-object-model.js"></script>
</head>
<body>
<div id="styles-a"></div>
<div id="styles-b"></div>
<div id="app"></div>
</body>
<script type="text/babel" data-presets="react">
'use strict';

run(async function () {
let updateStylesRoot
const App = () => {
const [stylesRoot, setStylesRoot] = React.useState(document.getElementById('styles-a'));

let resolve, promise;
promise = new Promise(r => resolve = r);

React.useEffect(() => () => requestIdleCallback(resolve));
updateStylesRoot = root => {
setStylesRoot(root);
return promise;
}

return (
<ReactScrollToBottom.default
className="react-scroll-to-bottom"
followButtonClassName="follow"
scrollViewClassName="scrollable"
styleOptions={{ stylesRoot }}
>
{pageObjects.paragraphs.map(paragraph => (
<p key={paragraph}>{paragraph}</p>
))}
</ReactScrollToBottom.default>
);
}
await new Promise(resolve =>
ReactDOM.render(
<App />,
document.getElementById('app'),
resolve
)
);

await pageObjects.scrollStabilizedAtBottom();

expect(document.getElementsByClassName('follow')[0]).toBeFalsy();

await pageObjects.mouseWheel(-100);

await pageObjects.scrollStabilized();

expect(document.getElementsByClassName('follow')[0]).toBeTruthy();
expect(document.getElementById('styles-a').childElementCount).toBeGreaterThan(0);
expect(document.getElementById('styles-b').childElementCount).toBe(0);

await updateStylesRoot(document.getElementById('styles-b'));

expect(document.getElementById('styles-a').childElementCount).toBe(0);
expect(document.getElementById('styles-b').childElementCount).toBeGreaterThan(0);
});
</script>
</html>
3 changes: 3 additions & 0 deletions __tests__/should-respect-container-change.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** @jest-environment ./packages/test-harness/JestEnvironment */

test('should-respect-container-change.html', () => runHTML('should-respect-container-change.html'));
75 changes: 75 additions & 0 deletions __tests__/should-respect-nonce-change.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="/react-scroll-to-bottom.development.js"></script>
<script src="/test-harness.js"></script>
<script src="/assets/page-object-model.js"></script>
</head>
<body>
<div id="styles-a"></div>
<div id="styles-b"></div>
<div id="app"></div>
</body>
<script type="text/babel" data-presets="react">
'use strict';

run(async function () {
let updateNonce
const App = () => {
const [nonce, setNonce] = React.useState('old-nonce');

let resolve, promise;
promise = new Promise(r => resolve = r);

React.useEffect(() => () => requestIdleCallback(resolve));
updateNonce = root => {
setNonce(root);
return promise;
}

return (
<ReactScrollToBottom.default
className="react-scroll-to-bottom"
followButtonClassName="follow"
nonce={nonce}
scrollViewClassName="scrollable"
>
{pageObjects.paragraphs.map(paragraph => (
<p key={paragraph}>{paragraph}</p>
))}
</ReactScrollToBottom.default>
);
}
await new Promise(resolve =>
ReactDOM.render(
<App />,
document.getElementById('app'),
resolve
)
);

await pageObjects.scrollStabilizedAtBottom();

expect(document.getElementsByClassName('follow')[0]).toBeFalsy();

await pageObjects.mouseWheel(-100);

await pageObjects.scrollStabilized();

const emotionStyle = document.querySelector('[data-emotion]')
const stylesCount = document.querySelectorAll('[data-emotion]').length;

expect(document.getElementsByClassName('follow')[0]).toBeTruthy();
expect(emotionStyle).toBeDefined();

await updateNonce('new-nonce');

expect(document.querySelector('[data-emotion]')).not.toEqual(emotionStyle);
expect(emotionStyle.parentElement).toBe(null);
expect(document.querySelectorAll('[data-emotion]').length).toBe(stylesCount);
});
</script>
</html>
3 changes: 3 additions & 0 deletions __tests__/should-respect-nonce-change.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** @jest-environment ./packages/test-harness/JestEnvironment */

test('should-respect-nonce-change.html', () => runHTML('should-respect-nonce-change.html'));
52 changes: 52 additions & 0 deletions __tests__/should-respect-options.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="/react-scroll-to-bottom.development.js"></script>
<script src="/test-harness.js"></script>
<script src="/assets/page-object-model.js"></script>
</head>
<body>
<div id="styles"></div>
<div id="app"></div>
</body>
<script type="text/babel" data-presets="react">
'use strict';

const styleOptions = {
stylesRoot: document.getElementById('styles')
};

run(async function () {
await new Promise(resolve =>
ReactDOM.render(
<ReactScrollToBottom.default
className="react-scroll-to-bottom"
followButtonClassName="follow"
scrollViewClassName="scrollable"
styleOptions={styleOptions}
>
{pageObjects.paragraphs.map(paragraph => (
<p key={paragraph}>{paragraph}</p>
))}
</ReactScrollToBottom.default>,
document.getElementById('app'),
resolve
)
);

await pageObjects.scrollStabilizedAtBottom();

expect(document.getElementsByClassName('follow')[0]).toBeFalsy();

await pageObjects.mouseWheel(-100);

await pageObjects.scrollStabilized();

expect(document.getElementsByClassName('follow')[0]).toBeTruthy();
expect(document.getElementById('styles').childElementCount).toBeGreaterThan(0);
});
</script>
</html>
3 changes: 3 additions & 0 deletions __tests__/should-respect-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** @jest-environment ./packages/test-harness/JestEnvironment */

test('should-respect-options.html', () => runHTML('should-respect-options.html'));
10 changes: 7 additions & 3 deletions packages/component/src/BasicScrollToBottom.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ const BasicScrollToBottom = ({
mode,
nonce,
scroller,
scrollViewClassName
scrollViewClassName,
styleOptions
}) => (
<Composer
checkInterval={checkInterval}
Expand All @@ -57,6 +58,7 @@ const BasicScrollToBottom = ({
mode={mode}
nonce={nonce}
scroller={scroller}
styleOptions={styleOptions}
>
<BasicScrollToBottomCore
className={className}
Expand All @@ -79,7 +81,8 @@ BasicScrollToBottom.defaultProps = {
mode: undefined,
nonce: undefined,
scroller: undefined,
scrollViewClassName: undefined
scrollViewClassName: undefined,
styleOptions: undefined
};

BasicScrollToBottom.propTypes = {
Expand All @@ -93,7 +96,8 @@ BasicScrollToBottom.propTypes = {
mode: PropTypes.oneOf(['bottom', 'top']),
nonce: PropTypes.string,
scroller: PropTypes.func,
scrollViewClassName: PropTypes.string
scrollViewClassName: PropTypes.string,
styleOptions: PropTypes.any
};

export default BasicScrollToBottom;
25 changes: 9 additions & 16 deletions packages/component/src/ScrollToBottom/Composer.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import createEmotion from '@emotion/css/create-instance';
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import createCSSKey from '../createCSSKey';
import createDebug from '../utils/debug';
import EventSpy from '../EventSpy';
import FunctionContext from './FunctionContext';
Expand All @@ -12,6 +10,7 @@ import State1Context from './State1Context';
import State2Context from './State2Context';
import StateContext from './StateContext';
import styleConsole from '../utils/styleConsole';
import useEmotion from '../hooks/internal/useEmotion';
import useStateRef from '../hooks/internal/useStateRef';

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

// We pool the emotion object by nonce.
// This is to make sure we don't generate too many unneeded <style> tags.
const emotionPool = {};

function setImmediateInterval(fn, ms) {
fn();

Expand Down Expand Up @@ -58,7 +53,8 @@ const Composer = ({
initialScrollBehavior,
mode,
nonce,
scroller
scroller,
styleOptions
}) => {
const debug = useMemo(() => createDebug(`<ScrollToBottom>`, { force: debugFromProp }), [debugFromProp]);

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

const styleToClassName = useMemo(() => {
const emotion =
emotionPool[nonce] ||
(emotionPool[nonce] = createEmotion({ key: 'react-scroll-to-bottom--css-' + createCSSKey(), nonce }));

return style => emotion.css(style) + '';
}, [nonce]);
const emotion = useEmotion(nonce, styleOptions?.stylesRoot);
const styleToClassName = useCallback(style => emotion.css(style) + '', [emotion]);

const internalContext = useMemo(
() => ({
Expand Down Expand Up @@ -626,7 +617,8 @@ Composer.defaultProps = {
initialScrollBehavior: 'smooth',
mode: undefined,
nonce: undefined,
scroller: DEFAULT_SCROLLER
scroller: DEFAULT_SCROLLER,
styleOptions: undefined
};

Composer.propTypes = {
Expand All @@ -637,7 +629,8 @@ Composer.propTypes = {
initialScrollBehavior: PropTypes.oneOf(['auto', 'smooth']),
mode: PropTypes.oneOf(['bottom', 'top']),
nonce: PropTypes.string,
scroller: PropTypes.func
scroller: PropTypes.func,
styleOptions: PropTypes.any
};

export default Composer;
Loading
Loading