├── .gitignore ├── demo ├── src │ ├── components │ │ ├── LogoIcon │ │ │ ├── index.ts │ │ │ ├── _logo-icon.scss │ │ │ └── LogoIcon.tsx │ │ └── App │ │ │ ├── index.tsx │ │ │ ├── _app.scss │ │ │ └── App.tsx │ ├── styles │ │ ├── style.scss │ │ ├── mixins │ │ │ ├── _responsive.scss │ │ │ └── _typography.scss │ │ ├── _base.scss │ │ ├── variables │ │ │ └── _colors.scss │ │ └── _global.scss │ ├── Index.tsx │ └── index.html ├── .babelrc ├── tsconfig.json ├── package.json └── webpack.config.js ├── .babelrc ├── .stylelintrc.js ├── .prettierrc.js ├── jest.config.js ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── .circleci └── config.yml ├── package.json ├── README.md └── src ├── __tests__ └── AutoScroll.tests.tsx └── AutoScroll.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .eslintcache 4 | coverage 5 | -------------------------------------------------------------------------------- /demo/src/components/LogoIcon/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LogoIcon'; 2 | -------------------------------------------------------------------------------- /demo/src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | 3 | export default App; 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-typescript" 5 | ] 6 | } -------------------------------------------------------------------------------- /demo/src/styles/style.scss: -------------------------------------------------------------------------------- 1 | @import '~@blueprintjs/core/lib/css/blueprint.css'; 2 | @import 'global'; 3 | 4 | -------------------------------------------------------------------------------- /demo/src/styles/mixins/_responsive.scss: -------------------------------------------------------------------------------- 1 | @mixin mobile { 2 | @media (max-width: 450px) { 3 | @content; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/styles/_base.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | @import 4 | 'mixins/responsive', 5 | 'mixins/typography'; 6 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'stylelint-config-sass-guidelines', 3 | rules: { 4 | 'max-nesting-depth': 3, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 100, 6 | tabWidth: 2 7 | }; 8 | -------------------------------------------------------------------------------- /demo/src/components/LogoIcon/_logo-icon.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/base'; 2 | 3 | .logo-icon { 4 | path { 5 | fill: map-get($colors, allen); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo/src/styles/variables/_colors.scss: -------------------------------------------------------------------------------- 1 | $colors: ( 2 | allen: #4e4e4e, 3 | ann: #e9f0f8, 4 | astor: #fff, 5 | barrow: #c9c9c9, 6 | bayard: #181818, 7 | beach: #5e5e5e, 8 | ); 9 | -------------------------------------------------------------------------------- /demo/src/Index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './styles/style.scss'; 5 | 6 | import App from './components/App'; 7 | 8 | ReactDOM.render(, document.querySelector('#root')); 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'enzyme', 4 | setupFilesAfterEnv: ['jest-enzyme'], 5 | testEnvironmentOptions: { 6 | enzymeAdapter: 'react16', 7 | }, 8 | testPathIgnorePatterns: ['/dist'], 9 | }; 10 | -------------------------------------------------------------------------------- /demo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/proposal-class-properties", 9 | "@babel/proposal-object-rest-spread" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "es2015"], 5 | "module": "commonjs", 6 | "declaration": true, 7 | "outDir": "dist", 8 | "strict": true, 9 | "jsx": "react", 10 | "esModuleInterop": true, 11 | "downlevelIteration": true, 12 | }, 13 | "include": [ 14 | "./src", 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "inlineSourceMap": true, 6 | "jsx": "react", 7 | "lib": ["es2017", "dom"], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "outDir": "dist", 11 | "pretty": true, 12 | "rootDir": "src", 13 | "strict": true, 14 | "target": "es2017" 15 | }, 16 | "include": ["src/"], 17 | "exclude": ["src/__tests__/**"] 18 | } 19 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | @brianmcallister/highlight-text 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/src/styles/mixins/_typography.scss: -------------------------------------------------------------------------------- 1 | @mixin type-style-manhattan { 2 | font-family: 'Barlow', sans-serif; 3 | font-size: 13px; 4 | font-weight: 500; 5 | } 6 | 7 | @mixin type-style-brooklyn { 8 | font-family: 'Barlow', sans-serif; 9 | font-size: 38px; 10 | font-weight: 400; 11 | } 12 | 13 | @mixin type-style-bronx { 14 | font-family: 'Barlow', sans-serif; 15 | font-size: 16px; 16 | font-weight: 500; 17 | line-height: 24px; 18 | } 19 | 20 | @mixin type-style-queens { 21 | font-family: 'Barlow', sans-serif; 22 | font-size: 20px; 23 | font-weight: 400; 24 | line-height: 32px; 25 | } 26 | -------------------------------------------------------------------------------- /demo/src/styles/_global.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | html, 8 | body { 9 | background: #181818; 10 | -moz-osx-font-smoothing: grayscale; 11 | -webkit-font-smoothing: antialiased; 12 | height: 100%; 13 | margin: 0; 14 | padding: 0; 15 | text-rendering: optimizeLegibility; 16 | width: 100%; 17 | } 18 | 19 | .button { 20 | appearance: none; 21 | background: rgba(#fff, 0.2); 22 | border: 0; 23 | border-radius: 3px; 24 | color: #c9c9c9; 25 | cursor: pointer; 26 | font-size: 15px; 27 | font-weight: 500; 28 | padding: 6px 8px 9px; 29 | text-align: center; 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | '@typescript-eslint', 4 | 'eslint-comments', 5 | 'jest', 6 | 'promise', 7 | ], 8 | extends: [ 9 | 'airbnb-typescript', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:eslint-comments/recommended', 12 | 'plugin:jest/recommended', 13 | 'plugin:promise/recommended', 14 | 'plugin:prettier/recommended', 15 | 'prettier/react', 16 | 'prettier/@typescript-eslint', 17 | ], 18 | env: { 19 | node: true, 20 | browser: true, 21 | jest: true, 22 | }, 23 | rules: { 24 | '@typescript-eslint/no-var-requires': 'off', 25 | '@typescript-eslint/explicit-function-return-type': 'off', 26 | '@typescript-eslint/ban-ts-ignore': 'off', 27 | 'jsx-a11y/label-has-associated-control': 'off', 28 | 'react/no-did-update-set-state': 'off', 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /demo/src/components/LogoIcon/LogoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './_logo-icon.scss'; 4 | 5 | /** 6 | * Base CSS class. 7 | * @private 8 | */ 9 | const baseClass = 'logo-icon'; 10 | 11 | /** 12 | * LogoIcon component. 13 | */ 14 | export default () => ( 15 | 22 | 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Brian Wm. McAllister (brianmcallister.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-auto-scroll-demo", 3 | "scripts": { 4 | "build": "NODE_ENV=production webpack --mode=production", 5 | "start": "webpack-dev-server --hot" 6 | }, 7 | "dependencies": { 8 | "@babel/core": "7.4.4", 9 | "@babel/plugin-proposal-class-properties": "7.7.0", 10 | "@babel/preset-env": "7.7.1", 11 | "@babel/preset-react": "7.7.0", 12 | "@babel/preset-typescript": "7.7.2", 13 | "@blueprintjs/core": "3.19.1", 14 | "@brianmcallister/highlight-text": "^2.0.1", 15 | "@brianmcallister/react-auto-scroll": "latest", 16 | "@types/faker": "4.1.7", 17 | "@types/node": "12.7.4", 18 | "@types/react": "16.8.2", 19 | "@types/react-dom": "16.8.0", 20 | "babel-loader": "8.0.6", 21 | "classnames": "2.2.6", 22 | "clean-webpack-plugin": "2.0.1", 23 | "css-loader": "2.1.0", 24 | "faker": "4.1.0", 25 | "html-webpack-plugin": "3.2.0", 26 | "react": "16.12.0", 27 | "react-dom": "16.12.0", 28 | "react-redux": "6.0.0", 29 | "sass": "1.23.3", 30 | "sass-loader": "8.0.0", 31 | "style-loader": "0.23.1", 32 | "typescript": "3.7.2", 33 | "webpack": "^4.44.1", 34 | "webpack-cli": "^3.3.12", 35 | "webpack-dev-server": "^3.11.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | 6 | const { NODE_ENV: env = 'development' } = process.env; 7 | 8 | module.exports = { 9 | mode: env, 10 | entry: './src/Index.tsx', 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | filename: env === 'development' ? 'js/[name].bundle.js' : 'js/[name]-[contenthash].bundle.js', 14 | }, 15 | resolve: { 16 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(ts|js)x?$/, 22 | include: path.resolve(__dirname, 'src'), 23 | loader: 'babel-loader', 24 | }, 25 | { 26 | test: /\.scss$/, 27 | use: ['style-loader', 'css-loader', 'sass-loader'], 28 | }, 29 | ], 30 | }, 31 | plugins: [ 32 | new CleanWebpackPlugin(), 33 | new webpack.DefinePlugin({ 34 | 'process.env': { 35 | NODE_ENV: JSON.stringify(env), 36 | }, 37 | }), 38 | new webpack.ProgressPlugin(), 39 | new HtmlWebpackPlugin({ 40 | template: path.resolve(__dirname, 'src', 'index.html'), 41 | }), 42 | ], 43 | }; 44 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | lint: 5 | docker: 6 | - image: circleci/node:lts 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - node-modules-{{ checksum "package.json" }} 12 | - node-modules- 13 | - run: npm ci 14 | - save_cache: 15 | paths: 16 | - node_modules 17 | key: node-modules-{{ checksum "package.json" }} 18 | - run: cd demo && npm ci && cd - 19 | - run: npm run lint 20 | 21 | stylelint: 22 | docker: 23 | - image: circleci/node:lts 24 | steps: 25 | - checkout 26 | - restore_cache: 27 | keys: 28 | - node-modules-{{ checksum "package.json" }} 29 | - node-modules- 30 | - run: npm ci 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: node-modules-{{ checksum "package.json" }} 35 | - run: npm run stylelint 36 | 37 | typescript: 38 | docker: 39 | - image: circleci/node:lts 40 | steps: 41 | - checkout 42 | - restore_cache: 43 | keys: 44 | - node-modules-{{ checksum "package.json" }} 45 | - node-modules- 46 | - run: npm ci 47 | - save_cache: 48 | paths: 49 | - node_modules 50 | key: node-modules-{{ checksum "package.json" }} 51 | - run: npm run types 52 | 53 | test: 54 | docker: 55 | - image: circleci/node:lts 56 | steps: 57 | - checkout 58 | - restore_cache: 59 | keys: 60 | - node-modules-{{ checksum "package.json" }} 61 | - node-modules- 62 | - run: npm ci 63 | - save_cache: 64 | paths: 65 | - node_modules 66 | key: node-modules-{{ checksum "package.json" }} 67 | - run: npm test -- --coverage 68 | - run: bash <(curl -s https://codecov.io/bash) -f ./coverage/coverage-final.json 69 | 70 | workflows: 71 | version: 2 72 | build: 73 | jobs: 74 | - lint 75 | - stylelint 76 | - typescript 77 | - test 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@brianmcallister/react-auto-scroll", 3 | "version": "1.1.0", 4 | "description": "Automatically scroll an element to the bottom", 5 | "main": "dist/AutoScroll.js", 6 | "repository": "brianmcallister/react-auto-scroll", 7 | "homepage": "https://react-auto-scroll.netlify.com/", 8 | "license": "MIT", 9 | "author": { 10 | "name": "Brian Wm. McAllister", 11 | "email": "brian@brianmcallister.com", 12 | "url": "https://www.brianmcallister.com" 13 | }, 14 | "files": [ 15 | "dist/**/*" 16 | ], 17 | "keywords": [ 18 | "autoscroll", 19 | "scroll", 20 | "react" 21 | ], 22 | "scripts": { 23 | "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --ignore-path .gitignore ./src ./demo", 24 | "prepare": "tsc", 25 | "stylelint": "stylelint demo/**/*.scss", 26 | "test": "jest --coverage", 27 | "types": "tsc --noEmit" 28 | }, 29 | "peerDependencies": { 30 | "react": "^16.8", 31 | "react-dom": "^16.8" 32 | }, 33 | "dependencies": { 34 | "classnames": "^2" 35 | }, 36 | "devDependencies": { 37 | "@types/classnames": "2.2.9", 38 | "@types/enzyme": "3.10.3", 39 | "@types/jest": "24.0.23", 40 | "@types/react": "16.9.13", 41 | "@typescript-eslint/eslint-plugin": "2.6.1", 42 | "enzyme": "3.10.0", 43 | "enzyme-adapter-react-16": "1.15.1", 44 | "eslint": "6.6.0", 45 | "eslint-config-airbnb": "18.0.1", 46 | "eslint-config-airbnb-typescript": "6.1.0", 47 | "eslint-config-prettier": "6.5.0", 48 | "eslint-plugin-eslint-comments": "3.1.2", 49 | "eslint-plugin-import": "2.18.2", 50 | "eslint-plugin-jest": "23.0.3", 51 | "eslint-plugin-jsx-a11y": "6.2.3", 52 | "eslint-plugin-prettier": "3.1.1", 53 | "eslint-plugin-promise": "4.2.1", 54 | "eslint-plugin-react": "7.16.0", 55 | "eslint-plugin-react-hooks": "1.7.0", 56 | "eslint-plugin-unicorn": "12.1.0", 57 | "jest": "^25.2.7", 58 | "jest-environment-enzyme": "7.1.2", 59 | "jest-enzyme": "7.1.2", 60 | "prettier": "1.19.1", 61 | "react": "16.12.0", 62 | "react-dom": "16.12.0", 63 | "stylelint": "^13.7.0", 64 | "stylelint-config-recommended": "3.0.0", 65 | "stylelint-config-sass-guidelines": "6.1.0", 66 | "stylelint-scss": "3.12.1", 67 | "ts-jest": "^25.3.1", 68 | "typescript": "3.7.2" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @brianmcallister/react-auto-scroll 2 | 3 | [![codecov](https://codecov.io/gh/brianmcallister/react-auto-scroll/branch/master/graph/badge.svg)](https://codecov.io/gh/brianmcallister/react-auto-scroll) [![CircleCI](https://circleci.com/gh/brianmcallister/react-auto-scroll.svg?style=svg)](https://circleci.com/gh/brianmcallister/react-auto-scroll) [![npm version](https://img.shields.io/npm/v/@brianmcallister/react-auto-scroll?label=version&color=%2354C536&logo=npm)](https://www.npmjs.com/package/@brianmcallister/react-auto-scroll) 4 | 5 | > Automatically scroll an element to the bottom 6 | 7 | `react-auto-scroll` is a React component that automatically scrolls a containing element to the bottom. 8 | 9 | ## Table of contents 10 | 11 | - [Demo](#demo) 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [API](#api) 15 | - [`AutoScroll`](#autoscroll) 16 | 17 | ## Demo 18 | 19 | Hosted demo: [https://react-auto-scroll.netlify.com/](https://react-auto-scroll.netlify.com/) 20 | 21 | You can also run the demo locally. To get started: 22 | 23 | ```sh 24 | git clone git@github.com:brianmcallister/react-auto-scroll.git 25 | cd react-auto-scroll/demo 26 | npm i 27 | # Optionally link libraries for local development 28 | npm link @brianmcallister/react-auto-scroll 29 | npm link ../node_modules/react 30 | npm start 31 | ``` 32 | 33 | ###### [⇡ Top](#table-of-contents) 34 | 35 | ## Installation 36 | 37 | ```sh 38 | npm install @brianmcallister/react-auto-scroll 39 | ``` 40 | 41 | ###### [⇡ Top](#table-of-contents) 42 | 43 | ## Usage 44 | 45 | ```js 46 | import AutoScroll from '@brianmcallister/react-auto-scroll'; 47 | 48 | const MyComponent = ({ someContent }) => ( 49 | 50 | {someContent} 51 | 52 | ); 53 | ``` 54 | 55 | ###### [⇡ Top](#table-of-contents) 56 | 57 | ## API 58 | 59 | ### `AutoScroll` 60 | 61 | This is the default export. Use this React component to scroll a container to the bottom when the children change. 62 | 63 | ```js 64 | interface Props { 65 | // ID attribute of the checkbox. 66 | checkBoxId?: string; 67 | // Children to render in the scroll container. 68 | children: React.ReactNode; 69 | // Extra CSS class names. 70 | className?: string; 71 | // Height value of the scroll container. 72 | height?: number; 73 | // Text to use for the auto scroll option. 74 | optionText?: string; 75 | // Prevent all mouse interaction with the scroll conatiner. 76 | preventInteraction?: boolean; 77 | // Ability to disable the smooth scrolling behavior. 78 | scrollBehavior?: 'smooth' | 'auto'; 79 | // Show the auto scroll option. 80 | showOption?: boolean; 81 | } 82 | ``` 83 | 84 | ###### [⇡ Top](#table-of-contents) 85 | -------------------------------------------------------------------------------- /src/__tests__/AutoScroll.tests.tsx: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import React from 'react'; 3 | 4 | import AutoScroll from '../AutoScroll'; 5 | 6 | describe('', () => { 7 | it('should render without errors', () => { 8 | const wrapper = mount( 9 | 10 |

test

11 |
, 12 | ); 13 | 14 | expect(wrapper).toBeDefined(); 15 | }); 16 | 17 | it('should render the children that get passed in', () => { 18 | const count = 10; 19 | const text = [...Array(count)].reduce((acc, next, index) => `${acc}${index}`, ''); 20 | // eslint-disable-next-line react/no-array-index-key 21 | const children = [...Array(count)].map((_, n) =>
{n}
); 22 | const wrapper = mount({children}); 23 | const container = wrapper.find('.react-auto-scroll__scroll-container'); 24 | 25 | expect(container.children().length).toBe(count); 26 | expect(container.text()).toStrictEqual(text); 27 | }); 28 | 29 | it('should handle the className prop correctly', () => { 30 | const wrapper = mount( 31 | 32 |

test

33 |
, 34 | ); 35 | 36 | expect(wrapper.hasClass('test-class')).toBe(true); 37 | }); 38 | 39 | it('should handle the height prop correctly', () => { 40 | const wrapper = mount( 41 | 42 |

test

43 |
, 44 | ); 45 | 46 | expect(wrapper.find('.react-auto-scroll__scroll-container').prop('style')).toMatchObject({ 47 | height: 10, 48 | }); 49 | }); 50 | 51 | it('should handle the optionText prop correctly', () => { 52 | const wrapper = mount( 53 | 54 |

test

55 |
, 56 | ); 57 | 58 | expect(wrapper.find('.react-auto-scroll__option-text').text()).toStrictEqual('test string'); 59 | }); 60 | 61 | it('should handle the preventInteraction prop correctly', () => { 62 | const wrapper = mount( 63 | 64 |

test

65 |
, 66 | ); 67 | 68 | expect(wrapper.find('.react-auto-scroll__scroll-container').prop('style')).toMatchObject({ 69 | pointerEvents: 'none', 70 | }); 71 | expect( 72 | wrapper.find('.react-auto-scroll').hasClass('react-auto-scroll--prevent-interaction'), 73 | ).toBe(true); 74 | expect(wrapper.find('.react-auto-scroll__option').length).toBe(0); 75 | }); 76 | 77 | it('should handle the showOption prop correctly', () => { 78 | const wrapper = mount( 79 | 80 |

test

81 |
, 82 | ); 83 | 84 | expect(wrapper.find('.react-auto-scroll__scroll-container').prop('style')).toMatchObject({ 85 | pointerEvents: 'auto', 86 | }); 87 | expect(wrapper.find('.react-auto-scroll__option').length).toBe(0); 88 | }); 89 | 90 | it('should handle the scrollBehavior prop correctly', () => { 91 | const wrapper = mount( 92 | 93 |

test

94 |
, 95 | ); 96 | 97 | expect(wrapper.find('.react-auto-scroll__scroll-container').prop('style')).toMatchObject({ 98 | scrollBehavior: 'auto', 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /demo/src/components/App/_app.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/base'; 2 | 3 | .app { 4 | align-items: stretch; 5 | display: flex; 6 | flex-direction: column; 7 | height: 100vh; 8 | 9 | &__header { 10 | @include type-style-manhattan; 11 | 12 | align-items: center; 13 | display: flex; 14 | justify-content: flex-end; 15 | margin: auto; 16 | padding: 30px; 17 | position: relative; 18 | width: 100%; 19 | z-index: 1; 20 | } 21 | 22 | &__header-link { 23 | color: map-get($colors, allen); 24 | margin-left: 10px; 25 | text-decoration: none; 26 | } 27 | 28 | &__header-links { 29 | a { 30 | color: map-get($colors, allen); 31 | margin-left: 10px; 32 | text-decoration: none; 33 | 34 | &:hover { 35 | text-decoration: underline; 36 | } 37 | } 38 | } 39 | 40 | &__content { 41 | align-items: center; 42 | display: flex; 43 | flex: 1; 44 | flex-direction: column; 45 | justify-content: center; 46 | margin: -50px auto 0; 47 | max-width: 1200px; 48 | padding: 0 50px 50px; 49 | 50 | @include mobile { 51 | justify-content: flex-start; 52 | padding: 70px 15px 15px; 53 | width: 100%; 54 | } 55 | } 56 | 57 | &__options { 58 | align-items: baseline; 59 | display: flex; 60 | margin-bottom: 50px; 61 | 62 | @include mobile { 63 | flex-wrap: wrap; 64 | margin-bottom: 20px; 65 | width: 100%; 66 | } 67 | 68 | label { 69 | @include type-style-bronx; 70 | 71 | color: map-get($colors, barrow); 72 | user-select: none; 73 | 74 | @include mobile { 75 | font-size: 13px; 76 | line-height: normal; 77 | } 78 | } 79 | } 80 | 81 | &__option { 82 | margin: 0 0 0 50px; 83 | 84 | @include mobile { 85 | margin: 0; 86 | padding: 10px 20px; 87 | width: 45%; 88 | } 89 | 90 | &:first-child { 91 | margin-left: 0; 92 | } 93 | 94 | .bp3-switch { 95 | margin: 0 !important; 96 | padding: 0 !important; 97 | } 98 | 99 | .bp3-control-indicator { 100 | margin: 0 !important; 101 | } 102 | 103 | .bp3-input-group { 104 | margin-top: 3px; 105 | } 106 | 107 | .bp3-slider { 108 | margin-top: 2px; 109 | } 110 | 111 | .bp3-slider-axis { 112 | display: none; 113 | } 114 | } 115 | 116 | &__msg-container { 117 | background: #1d1f21; 118 | border-radius: 5px; 119 | padding: 10px 0; 120 | width: 70vw; 121 | 122 | @include mobile { 123 | width: 100%; 124 | } 125 | } 126 | 127 | &__msg { 128 | color: map-get($colors, beach); 129 | cursor: default; 130 | font-family: monospace; 131 | padding: 5px 12px; 132 | white-space: nowrap; 133 | 134 | &:hover { 135 | background-color: lighten(#1d1f21, 2%); 136 | } 137 | } 138 | 139 | .react-auto-scroll { 140 | &__scroll-container { 141 | overflow-x: hidden !important; 142 | } 143 | 144 | &__option { 145 | @include type-style-manhattan; 146 | 147 | color: map-get($colors, barrow); 148 | cursor: pointer; 149 | display: inline-block; 150 | padding: 12px 10px 0; 151 | } 152 | 153 | &__option-input { 154 | cursor: pointer; 155 | margin-right: 10px; 156 | } 157 | 158 | &__option-text { 159 | cursor: pointer; 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/AutoScroll.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | // ID attribute of the checkbox. 6 | checkBoxId?: string; 7 | // Children to render in the scroll container. 8 | children: React.ReactNode; 9 | // Extra CSS class names. 10 | className?: string; 11 | // Height value of the scroll container. 12 | height?: number; 13 | // Text to use for the auto scroll option. 14 | optionText?: string; 15 | // Prevent all mouse interaction with the scroll conatiner. 16 | preventInteraction?: boolean; 17 | // Ability to disable the smooth scrolling behavior. 18 | scrollBehavior?: 'smooth' | 'auto'; 19 | // Show the auto scroll option. 20 | showOption?: boolean; 21 | } 22 | 23 | /** 24 | * Base CSS class. 25 | * @private 26 | */ 27 | const baseClass = 'react-auto-scroll'; 28 | 29 | /** 30 | * Get a random string. 31 | * @private 32 | */ 33 | const getRandomString = () => 34 | Math.random() 35 | .toString(36) 36 | .slice(2, 15); 37 | 38 | /** 39 | * AutoScroll component. 40 | */ 41 | export default function AutoScroll({ 42 | checkBoxId = getRandomString(), 43 | children, 44 | className, 45 | height, 46 | optionText = 'Auto scroll', 47 | preventInteraction = false, 48 | scrollBehavior = 'smooth', 49 | showOption = true, 50 | }: Props) { 51 | const [autoScroll, setAutoScroll] = React.useState(true); 52 | const containerElement = React.useRef(null); 53 | const cls = classnames(baseClass, className, { 54 | [`${baseClass}--empty`]: React.Children.count(children) === 0, 55 | [`${baseClass}--prevent-interaction`]: preventInteraction, 56 | [`${baseClass}--showOption`]: showOption, 57 | }); 58 | const style = { 59 | height, 60 | overflow: 'auto', 61 | scrollBehavior: 'auto', 62 | pointerEvents: preventInteraction ? 'none' : 'auto', 63 | } as const; 64 | 65 | // Handle mousewheel events on the scroll container. 66 | const onWheel = () => { 67 | const { current } = containerElement; 68 | 69 | if (current && showOption) { 70 | setAutoScroll(current.scrollTop + current.offsetHeight === current.scrollHeight); 71 | } 72 | }; 73 | 74 | // Apply the scroll behavior property after the first render, 75 | // so that the initial render is scrolled all the way to the bottom. 76 | React.useEffect(() => { 77 | setTimeout(() => { 78 | const { current } = containerElement; 79 | 80 | if (current) { 81 | current.style.scrollBehavior = scrollBehavior; 82 | } 83 | }, 0); 84 | }, [containerElement, scrollBehavior]); 85 | 86 | // When the children are updated, scroll the container 87 | // to the bottom. 88 | React.useEffect(() => { 89 | if (!autoScroll) { 90 | return; 91 | } 92 | 93 | const { current } = containerElement; 94 | 95 | if (current) { 96 | current.scrollTop = current.scrollHeight; 97 | } 98 | }, [children, containerElement, autoScroll]); 99 | 100 | return ( 101 |
102 |
108 | {children} 109 |
110 | 111 | {showOption && !preventInteraction && ( 112 |
113 | setAutoScroll(!autoScroll)} 118 | type="checkbox" 119 | /> 120 | 121 | 127 |
128 | )} 129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /demo/src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import AutoScroll from '@brianmcallister/react-auto-scroll'; 2 | import faker from 'faker'; 3 | import React from 'react'; 4 | import { Label, FocusStyleManager, Switch, Slider, InputGroup, FormGroup } from '@blueprintjs/core'; 5 | 6 | import LogoIcon from '../LogoIcon'; 7 | 8 | import './_app.scss'; 9 | 10 | FocusStyleManager.onlyShowFocusOnTabs(); 11 | 12 | const DEFAULT_HEIGHT = 300; 13 | const baseClass = 'app'; 14 | 15 | /** 16 | * App component. 17 | */ 18 | export default () => { 19 | const [disableSmoothScroll, setDisableSmoothScroll] = React.useState(false); 20 | const [preventInteraction, setPreventInteraction] = React.useState(false); 21 | const [autoScroll, setAutoScroll] = React.useState(true); 22 | const [optionText, setOptionText] = React.useState('Auto scroll'); 23 | const [height, setHeight] = React.useState(DEFAULT_HEIGHT); 24 | const [messages, setMessages] = React.useState([]); 25 | 26 | React.useEffect(() => { 27 | const interval = setInterval(() => { 28 | if (Math.random() > 0.5) { 29 | return; 30 | } 31 | 32 | const msg = [ 33 | faker.internet.protocol(), 34 | `/${faker.system.commonFileType()}/${faker.system.commonFileName( 35 | faker.system.commonFileExt(), 36 | )}`, 37 | faker.random.number(), 38 | faker.system.mimeType(), 39 | faker.system.semver(), 40 | faker.random.locale(), 41 | ]; 42 | 43 | setMessages(messages.concat([msg.join(' ')])); 44 | }, 100); 45 | 46 | return () => clearInterval(interval); 47 | }, [messages]); 48 | 49 | return ( 50 |
51 |
52 | 53 | 54 | 55 | Brian Wm. McAllister 56 | 57 | 58 |
59 | GitHub 60 | npm 61 |
62 |
63 | 64 |
65 |
66 |
67 | 68 | 69 | setPreventInteraction(!preventInteraction)} 72 | checked={preventInteraction} 73 | large 74 | inline 75 | alignIndicator="right" 76 | /> 77 |
78 | 79 |
80 | 81 | 82 | setAutoScroll(!autoScroll)} 85 | checked={autoScroll} 86 | large 87 | inline 88 | alignIndicator="right" 89 | /> 90 |
91 | 92 |
93 | 94 | 95 | setDisableSmoothScroll(!disableSmoothScroll)} 98 | checked={disableSmoothScroll} 99 | large 100 | inline 101 | alignIndicator="right" 102 | /> 103 |
104 | 105 | 106 | ) => 109 | setOptionText(event.currentTarget.value) 110 | } 111 | /> 112 | 113 | 114 | 115 | 116 | 117 |
118 | 119 |
120 | 127 | {messages.map(msg => { 128 | return ( 129 |
130 | {msg} 131 |
132 | ); 133 | })} 134 |
135 |
136 |
137 |
138 | ); 139 | }; 140 | --------------------------------------------------------------------------------