├── .eslintignore ├── .babelrc ├── typings.d.ts ├── src ├── types.d.ts ├── utils │ ├── returnFalse.ts │ ├── isString.ts │ ├── getInnerHeight.ts │ ├── getInnerWidth.ts │ ├── index.ts │ ├── mergeClasses.ts │ └── getScrollbarWidth.ts ├── index.ts └── Scrollbars │ ├── styles.ts │ ├── types.ts │ └── index.tsx ├── .npmignore ├── public ├── favicon.ico ├── scrollbar-macos.png ├── scrollbar-components.png └── logo.svg ├── .gitignore ├── CHANGELOG.md ├── .prettierrc ├── test ├── browser.spec.js ├── utils.spec.js ├── .eslintrc ├── mobile.spec.js └── Scrollbars │ ├── resizing.js │ ├── index.js │ ├── flexbox.js │ ├── hideTracks.js │ ├── clickTrack.js │ ├── onUpdate.js │ ├── dragThumb.js │ ├── gettersSetters.js │ ├── universal.js │ ├── autoHeight.js │ ├── scrolling.js │ ├── rendering.js │ └── autoHide.js ├── test.js ├── .umirc.ts ├── .eslintrc ├── prepublish.js ├── tsconfig.json ├── docs ├── components │ ├── Lorem.tsx │ ├── SpringScrollbars │ │ ├── SpringScrollbarsExample.js │ │ └── SpringScrollbars.js │ ├── ColoredScrollbars.tsx │ └── ShadowScrollbars │ │ └── ShadowScrollbars.tsx ├── usage.md ├── API.md ├── README.md ├── demo.md └── customization.md ├── LICENSE ├── CODE_OF_CONDUCT.md ├── karma.conf.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | **/node_modules 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-1"] 3 | } 4 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'dom-css'; 2 | declare module 'classnames'; 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | src 4 | test 5 | examples 6 | coverage 7 | .idea 8 | docs 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakhnyuk/rc-scrollbars/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/utils/returnFalse.ts: -------------------------------------------------------------------------------- 1 | export default function returnFalse() { 2 | return false; 3 | } 4 | -------------------------------------------------------------------------------- /public/scrollbar-macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakhnyuk/rc-scrollbars/HEAD/public/scrollbar-macos.png -------------------------------------------------------------------------------- /src/utils/isString.ts: -------------------------------------------------------------------------------- 1 | export default function isString(maybe: any) { 2 | return typeof maybe === 'string'; 3 | } 4 | -------------------------------------------------------------------------------- /public/scrollbar-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakhnyuk/rc-scrollbars/HEAD/public/scrollbar-components.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | dist 5 | lib 6 | coverage 7 | examples/simple/static 8 | .idea/ 9 | 10 | # umi 11 | .umi 12 | .umi-production 13 | .env.local 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | [Have a look at the releases](https://github.com/sakhnyuk/rc-scrollbars/releases) 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from './Scrollbars'; 2 | 3 | export { ScrollValues, StyleClasses, ScrollbarsProps } from './Scrollbars/types'; 4 | export { Scrollbars }; 5 | 6 | export default Scrollbars; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": true, 6 | "printWidth": 100, 7 | "trailingComma": "all", 8 | "proseWrap": "never", 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /test/browser.spec.js: -------------------------------------------------------------------------------- 1 | import getScrollbarWidth from '../src/utils/getScrollbarWidth'; 2 | import createTests from './Scrollbars'; 3 | 4 | describe('Scrollbars (browser)', () => { 5 | createTests(getScrollbarWidth(), getScrollbarWidth()); 6 | }); 7 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | window.expect = expect; 3 | window.createSpy = expect.createSpy; 4 | window.spyOn = expect.spyOn; 5 | window.isSpy = expect.isSpy; 6 | 7 | const context = require.context('./test', true, /\.spec\.js$/); 8 | context.keys().forEach(context); 9 | -------------------------------------------------------------------------------- /test/utils.spec.js: -------------------------------------------------------------------------------- 1 | import returnFalse from '../src/utils/returnFalse'; 2 | describe('utils', () => { 3 | describe('returnFalse', () => { 4 | it('should return false', (done) => { 5 | expect(returnFalse()).toEqual(false); 6 | done(); 7 | }); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/getInnerHeight.ts: -------------------------------------------------------------------------------- 1 | export default function getInnerHeight(el?: HTMLElement) { 2 | if (!el) return 0; 3 | const { clientHeight } = el; 4 | const { paddingTop, paddingBottom } = getComputedStyle(el); 5 | return clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/getInnerWidth.ts: -------------------------------------------------------------------------------- 1 | export default function getInnerWidth(el?: HTMLDivElement) { 2 | if (!el) return 0; 3 | const { clientWidth } = el; 4 | const { paddingLeft, paddingRight } = getComputedStyle(el); 5 | return clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight); 6 | } 7 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | export default defineConfig({ 4 | title: 'rc-scrollbar', 5 | mode: 'doc', 6 | logo: '/logo.svg', 7 | favicon: '/favicon.ico', 8 | exportStatic: {}, 9 | hash: true, 10 | resolve: { 11 | includes: ['docs'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["react-app", "plugin:prettier/recommended", "prettier"], 4 | "plugins": ["prettier"], 5 | "env": { 6 | "browser": true, 7 | "mocha": true, 8 | "node": true 9 | }, 10 | "rules": { 11 | "prettier/prettier": "error" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "describe": true, 4 | "it": true, 5 | "expect": true, 6 | "before": true, 7 | "beforeEach": true, 8 | "after": true, 9 | "afterEach": true, 10 | "createSpy": true, 11 | "spyOn": true, 12 | "isSpy": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as getInnerHeight } from './getInnerHeight'; 2 | export { default as getInnerWidth } from './getInnerWidth'; 3 | export { default as getScrollbarWidth } from './getScrollbarWidth'; 4 | export { default as isString } from './isString'; 5 | export { default as getFinalClasses } from './mergeClasses'; 6 | export { default as returnFalse } from './returnFalse'; 7 | -------------------------------------------------------------------------------- /prepublish.js: -------------------------------------------------------------------------------- 1 | var glob = require('glob'); 2 | var fs = require('fs'); 3 | var es3ify = require('es3ify'); 4 | 5 | glob('./@(lib|dist)/**/*.js', function (err, files) { 6 | if (err) throw err; 7 | 8 | files.forEach(function (file) { 9 | fs.readFile(file, 'utf8', function (err, data) { 10 | if (err) throw err; 11 | fs.writeFile(file, es3ify.transform(data), function (err) { 12 | if (err) throw err; 13 | console.log('es3ified ' + file); 14 | }); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/mobile.spec.js: -------------------------------------------------------------------------------- 1 | import createTests from './Scrollbars'; 2 | const getScrollbarWidthModule = require('../src/utils/getScrollbarWidth'); 3 | const envScrollbarWidth = getScrollbarWidthModule.default(); 4 | 5 | describe('Scrollbars (mobile)', () => { 6 | const mobileScrollbarsWidth = 0; 7 | let getScrollbarWidthSpy; 8 | 9 | before(() => { 10 | getScrollbarWidthSpy = spyOn(getScrollbarWidthModule, 'default'); 11 | getScrollbarWidthSpy.andReturn(mobileScrollbarsWidth); 12 | }); 13 | 14 | after(() => { 15 | getScrollbarWidthSpy.restore(); 16 | }); 17 | 18 | createTests(mobileScrollbarsWidth, envScrollbarWidth); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": [ 6 | "ES2015", 7 | "ES2016", 8 | "ES2017", 9 | "ES2018", 10 | "ES2019", 11 | "ES2020", 12 | "dom", 13 | "dom.iterable", 14 | "esnext" 15 | ], 16 | "allowJs": true, 17 | "jsx": "react", 18 | "declaration": true, 19 | "outDir": "lib", 20 | "removeComments": true, 21 | "strict": true, 22 | "noImplicitAny": false, 23 | "rootDirs": [ 24 | "src" 25 | ], 26 | "esModuleInterop": true, 27 | "skipLibCheck": true, 28 | "forceConsistentCasingInFileNames": true, 29 | "baseUrl": "./", 30 | "allowSyntheticDefaultImports": true 31 | }, 32 | "include": [ 33 | "src" 34 | ], 35 | "exclude": [ 36 | "node_modules", 37 | "test", 38 | "example", 39 | "lib" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /docs/components/Lorem.tsx: -------------------------------------------------------------------------------- 1 | export const Lorem = () => { 2 | const lorem = `Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam 3 | nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam 4 | erat, sed diam voluptua. At vero eos et accusam et justo duo dolores 5 | et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est 6 | Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur 7 | sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore 8 | et dolore magna aliquyam erat, sed diam voluptua. At vero eos et 9 | accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, 10 | no sea takimata sanctus est Lorem ipsum dolor sit amet.`; 11 | 12 | return ( 13 | <> 14 |

{lorem}

15 |

{lorem}

16 |

{lorem}

17 |

{lorem}

18 |

{lorem}

19 |

{lorem}

20 |

{lorem}

21 |

{lorem}

22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mikhail Sakhniuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/mergeClasses.ts: -------------------------------------------------------------------------------- 1 | import { ScrollbarsProps, StyleClasses } from '..'; 2 | 3 | const defaultClasses: StyleClasses = { 4 | root: 'rc-scrollbars-container', 5 | view: 'rc-scrollbars-view', 6 | trackVertical: 'rc-scrollbars-track rc-scrollbars-track-v', 7 | trackHorizontal: 'rc-scrollbars-track rc-scrollbars-track-h', 8 | thumbVertical: 'rc-scrollbars-thumb rc-scrollbars-thumb-v', 9 | thumbHorizontal: 'rc-scrollbars-thumb rc-scrollbars-thumb-h', 10 | }; 11 | 12 | function mergeClasses(defaultClasses, providedClasses) { 13 | return providedClasses 14 | ? Object.keys(defaultClasses).reduce((result, classKey) => { 15 | result[classKey] = `${defaultClasses[classKey]} ${providedClasses[classKey] || ''}`; 16 | return result; 17 | }, {}) 18 | : defaultClasses; 19 | } 20 | 21 | export default function getFinalClasses(props: ScrollbarsProps): StyleClasses { 22 | const { className, classes } = props; 23 | const { root: defaultRootClass, ...rest } = defaultClasses; 24 | 25 | return { 26 | root: [defaultRootClass, className, classes?.root].filter(Boolean).join(' '), 27 | ...mergeClasses(rest, props.classes), 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /test/Scrollbars/resizing.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | import simulant from 'simulant'; 4 | 5 | export default function createTests(scrollbarWidth) { 6 | // Not for mobile environment 7 | if (!scrollbarWidth) return; 8 | 9 | let node; 10 | beforeEach(() => { 11 | node = document.createElement('div'); 12 | document.body.appendChild(node); 13 | }); 14 | afterEach(() => { 15 | unmountComponentAtNode(node); 16 | document.body.removeChild(node); 17 | }); 18 | 19 | describe('when resizing window', () => { 20 | it('should update scrollbars', (done) => { 21 | render( 22 | 23 |
24 | , 25 | node, 26 | function callback() { 27 | setTimeout(() => { 28 | const spy = spyOn(this, 'update'); 29 | simulant.fire(window, 'resize'); 30 | expect(spy.calls.length).toEqual(1); 31 | done(); 32 | }, 100); 33 | }, 34 | ); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/Scrollbars/index.js: -------------------------------------------------------------------------------- 1 | import rendering from './rendering'; 2 | import gettersSetters from './gettersSetters'; 3 | import scrolling from './scrolling'; 4 | import resizing from './resizing'; 5 | import clickTrack from './clickTrack'; 6 | import dragThumb from './dragThumb'; 7 | import flexbox from './flexbox'; 8 | import autoHide from './autoHide'; 9 | import autoHeight from './autoHeight'; 10 | import hideTracks from './hideTracks'; 11 | import universal from './universal'; 12 | import onUpdate from './onUpdate'; 13 | 14 | export default function createTests(scrollbarWidth, envScrollbarWidth) { 15 | rendering(scrollbarWidth, envScrollbarWidth); 16 | gettersSetters(scrollbarWidth, envScrollbarWidth); 17 | scrolling(scrollbarWidth, envScrollbarWidth); 18 | resizing(scrollbarWidth, envScrollbarWidth); 19 | clickTrack(scrollbarWidth, envScrollbarWidth); 20 | dragThumb(scrollbarWidth, envScrollbarWidth); 21 | flexbox(scrollbarWidth, envScrollbarWidth); 22 | autoHide(scrollbarWidth, envScrollbarWidth); 23 | autoHeight(scrollbarWidth, envScrollbarWidth); 24 | hideTracks(scrollbarWidth, envScrollbarWidth); 25 | universal(scrollbarWidth, envScrollbarWidth); 26 | onUpdate(scrollbarWidth, envScrollbarWidth); 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/getScrollbarWidth.ts: -------------------------------------------------------------------------------- 1 | import css from 'dom-css'; 2 | 3 | let scrollbarWidth: number | undefined = undefined; 4 | let pxRatio: number = getPxRatio(); 5 | 6 | export default function getScrollbarWidth() { 7 | /** 8 | * Check zoom ratio. If it was changed, then it would update scrollbatWidth 9 | */ 10 | const newPxRatio = getPxRatio(); 11 | 12 | if (pxRatio !== newPxRatio) { 13 | scrollbarWidth = getScrollbarWidthFromDom(); 14 | pxRatio = newPxRatio; 15 | } 16 | 17 | if (typeof scrollbarWidth === 'number') return scrollbarWidth; 18 | 19 | /* istanbul ignore else */ 20 | if (typeof document !== 'undefined') { 21 | scrollbarWidth = getScrollbarWidthFromDom(); 22 | } else { 23 | scrollbarWidth = 0; 24 | } 25 | 26 | return scrollbarWidth || 0; 27 | } 28 | 29 | function getScrollbarWidthFromDom() { 30 | const div = document.createElement('div'); 31 | 32 | css(div, { 33 | width: 100, 34 | height: 100, 35 | position: 'absolute', 36 | top: -9999, 37 | overflow: 'scroll', 38 | MsOverflowStyle: 'scrollbar', 39 | }); 40 | 41 | document.body.appendChild(div); 42 | const result = div.offsetWidth - div.clientWidth; 43 | document.body.removeChild(div); 44 | 45 | return result; 46 | } 47 | 48 | function getPxRatio() { 49 | if (typeof window === 'undefined') return 1; 50 | return window.screen.availWidth / document.documentElement.clientWidth; 51 | } 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | 15 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint no-var: 0, no-unused-vars: 0 */ 2 | var path = require('path'); 3 | var webpack = require('webpack'); 4 | var runCoverage = process.env.COVERAGE === 'true'; 5 | 6 | var coverageLoaders = []; 7 | var coverageReporters = []; 8 | 9 | if (runCoverage) { 10 | coverageLoaders.push({ 11 | test: /\.js$/, 12 | include: path.resolve('src/'), 13 | loader: 'isparta' 14 | }); 15 | coverageReporters.push('coverage'); 16 | } 17 | 18 | module.exports = function karmaConfig(config) { 19 | config.set({ 20 | browsers: ['Chrome'], 21 | singleRun: true, 22 | frameworks: ['mocha'], 23 | files: ['./test.js'], 24 | preprocessors: { 25 | './test.js': ['webpack', 'sourcemap'] 26 | }, 27 | reporters: ['mocha'].concat(coverageReporters), 28 | webpack: { 29 | devtool: 'inline-source-map', 30 | resolve: { 31 | alias: { 32 | 'react-custom-scrollbars': path.resolve(__dirname, './src') 33 | } 34 | }, 35 | module: { 36 | loaders: [{ 37 | test: /\.js$/, 38 | loader: 'babel', 39 | exclude: /(node_modules)/ 40 | }].concat(coverageLoaders) 41 | } 42 | }, 43 | coverageReporter: { 44 | dir: 'coverage/', 45 | reporters: [ 46 | { type: 'html', subdir: 'report-html' }, 47 | { type: 'text', subdir: '.', file: 'text.txt' }, 48 | { type: 'text-summary', subdir: '.', file: 'text-summary.txt' }, 49 | ] 50 | } 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /docs/components/SpringScrollbars/SpringScrollbarsExample.js: -------------------------------------------------------------------------------- 1 | import random from 'lodash/random'; 2 | import { Component } from 'react'; 3 | import SpringScrollbars from './SpringScrollbars'; 4 | import { Lorem } from '../Lorem'; 5 | 6 | export default class SpringScrollbarsExample extends Component { 7 | constructor(props, ...rest) { 8 | super(props, ...rest); 9 | this.handleClickRandomPosition = this.handleClickRandomPosition.bind(this); 10 | } 11 | 12 | handleClickRandomPosition() { 13 | const { scrollbars } = this.refs; 14 | const scrollHeight = scrollbars.getScrollHeight(); 15 | scrollbars.scrollTop(random(scrollHeight)); 16 | } 17 | 18 | render() { 19 | return ( 20 |
21 | 22 | 23 | 24 | 25 | 28 | 29 |
30 |

31 | The Scrollbars are animated with{' '} 32 | 38 | Rebound 39 | 40 | . You can simply animate the Scrollbars with scrollbars.scrollTop(x). 41 |

42 |

43 | Don't forget to wrap your steps with requestAnimationFrame. 44 |

45 |
46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/Scrollbars/flexbox.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode, findDOMNode } from 'react-dom'; 3 | import { Component } from 'react'; 4 | 5 | export default function createTests() { 6 | let node; 7 | beforeEach(() => { 8 | node = document.createElement('div'); 9 | document.body.appendChild(node); 10 | }); 11 | afterEach(() => { 12 | unmountComponentAtNode(node); 13 | document.body.removeChild(node); 14 | }); 15 | describe('when scrollbars are in flexbox environment', () => { 16 | it('should still work', (done) => { 17 | class Root extends Component { 18 | render() { 19 | return ( 20 |
31 | { 33 | this.scrollbars = ref; 34 | }} 35 | > 36 |
37 | 38 |
39 | ); 40 | } 41 | } 42 | render(, node, function callback() { 43 | setTimeout(() => { 44 | const { scrollbars } = this; 45 | const $scrollbars = findDOMNode(scrollbars); 46 | const $view = scrollbars.view; 47 | expect($scrollbars.clientHeight).toBeGreaterThan(0); 48 | expect($view.clientHeight).toBeGreaterThan(0); 49 | done(); 50 | }, 100); 51 | }); 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /docs/components/SpringScrollbars/SpringScrollbars.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { Scrollbars } from 'rc-scrollbars'; 3 | import { SpringSystem, MathUtil } from 'rebound'; 4 | 5 | export default class SpringScrollbars extends Component { 6 | constructor(props, ...rest) { 7 | super(props, ...rest); 8 | this.handleSpringUpdate = this.handleSpringUpdate.bind(this); 9 | } 10 | 11 | componentDidMount() { 12 | this.springSystem = new SpringSystem(); 13 | this.spring = this.springSystem.createSpring(); 14 | this.spring.addListener({ onSpringUpdate: this.handleSpringUpdate }); 15 | } 16 | 17 | componentWillUnmount() { 18 | this.springSystem.deregisterSpring(this.spring); 19 | this.springSystem.removeAllListeners(); 20 | this.springSystem = undefined; 21 | this.spring.destroy(); 22 | this.spring = undefined; 23 | } 24 | 25 | getScrollTop() { 26 | return this.refs.scrollbars.getScrollTop(); 27 | } 28 | 29 | getScrollHeight() { 30 | return this.refs.scrollbars.getScrollHeight(); 31 | } 32 | 33 | getHeight() { 34 | return this.refs.scrollbars.getHeight(); 35 | } 36 | 37 | scrollTop(top) { 38 | const { scrollbars } = this.refs; 39 | const scrollTop = scrollbars.getScrollTop(); 40 | const scrollHeight = scrollbars.getScrollHeight(); 41 | const val = MathUtil.mapValueInRange( 42 | top, 43 | 0, 44 | scrollHeight, 45 | scrollHeight * 0.2, 46 | scrollHeight * 0.8, 47 | ); 48 | this.spring.setCurrentValue(scrollTop).setAtRest(); 49 | this.spring.setEndValue(val); 50 | } 51 | 52 | handleSpringUpdate(spring) { 53 | const { scrollbars } = this.refs; 54 | const val = spring.getCurrentValue(); 55 | scrollbars.scrollTop(val); 56 | } 57 | 58 | render() { 59 | return ; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/components/ColoredScrollbars.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, HTMLAttributes } from 'react'; 2 | import { Scrollbars, ScrollValues } from 'rc-scrollbars'; 3 | 4 | type State = { 5 | top: number; 6 | }; 7 | 8 | export default class ColoredScrollbars extends Component { 9 | state: State; 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { top: 0 }; 14 | this.handleUpdate = this.handleUpdate.bind(this); 15 | this.renderView = this.renderView.bind(this); 16 | this.renderThumb = this.renderThumb.bind(this); 17 | } 18 | 19 | handleUpdate = (values: ScrollValues) => { 20 | const { top } = values; 21 | this.setState({ top }); 22 | }; 23 | 24 | renderView = ({ style, ...props }: HTMLAttributes) => { 25 | const { top } = this.state; 26 | const viewStyle = { 27 | padding: 15, 28 | backgroundColor: `rgb(${Math.round(255 - top * 255)}, ${Math.round(top * 255)}, ${Math.round( 29 | 255, 30 | )})`, 31 | color: `rgb(${Math.round(255 - top * 255)}, ${Math.round(255 - top * 255)}, ${Math.round( 32 | 255 - top * 255, 33 | )})`, 34 | }; 35 | return
; 36 | }; 37 | 38 | renderThumb = ({ style, ...props }: HTMLAttributes) => { 39 | const { top } = this.state; 40 | const thumbStyle = { 41 | backgroundColor: `rgb(${Math.round(255 - top * 255)}, ${Math.round( 42 | 255 - top * 255, 43 | )}, ${Math.round(255 - top * 255)})`, 44 | borderRadius: 'inherit', 45 | }; 46 | return
; 47 | }; 48 | 49 | render() { 50 | return ( 51 | 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Scrollbars/styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import { StyleKeys } from './types'; 3 | 4 | export function createStyles(disableDefaultStyles: boolean): Record { 5 | const trackStyleDefault: CSSProperties = { 6 | position: 'absolute', 7 | right: 2, 8 | bottom: 2, 9 | zIndex: 100, 10 | ...(!disableDefaultStyles && { borderRadius: 3 }), 11 | }; 12 | 13 | return { 14 | containerStyleDefault: { 15 | position: 'relative', 16 | overflow: 'hidden', 17 | width: '100%', 18 | height: '100%', 19 | }, 20 | 21 | containerStyleAutoHeight: { 22 | height: 'auto', 23 | }, 24 | 25 | viewStyleDefault: { 26 | position: 'absolute', 27 | top: 0, 28 | left: 0, 29 | right: 0, 30 | bottom: 0, 31 | overflow: 'scroll', 32 | WebkitOverflowScrolling: 'touch', 33 | }, 34 | 35 | // Overrides viewStyleDefault properties 36 | viewStyleAutoHeight: { 37 | position: 'relative', 38 | top: undefined, 39 | left: undefined, 40 | right: undefined, 41 | bottom: undefined, 42 | }, 43 | 44 | viewStyleUniversalInitial: { 45 | overflow: 'hidden', 46 | marginRight: 0, 47 | marginBottom: 0, 48 | }, 49 | 50 | trackHorizontalStyleDefault: { 51 | ...trackStyleDefault, 52 | left: 2, 53 | height: 6, 54 | }, 55 | 56 | trackVerticalStyleDefault: { 57 | ...trackStyleDefault, 58 | top: 2, 59 | width: 6, 60 | }, 61 | 62 | thumbStyleDefault: { 63 | position: 'relative', 64 | display: 'block', 65 | height: '100%', 66 | cursor: 'pointer', 67 | borderRadius: 'inherit', 68 | ...(!disableDefaultStyles && { backgroundColor: 'rgba(0,0,0,.2)' }), 69 | }, 70 | 71 | disableSelectStyle: { 72 | userSelect: 'none', 73 | }, 74 | 75 | disableSelectStyleReset: { 76 | userSelect: 'auto', 77 | }, 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /docs/components/ShadowScrollbars/ShadowScrollbars.tsx: -------------------------------------------------------------------------------- 1 | import { Component, CSSProperties } from 'react'; 2 | import { Scrollbars } from 'rc-scrollbars'; 3 | import css from 'dom-css'; 4 | 5 | type Props = { 6 | style: CSSProperties; 7 | }; 8 | 9 | class ShadowScrollbars extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | scrollTop: 0, 14 | scrollHeight: 0, 15 | clientHeight: 0, 16 | }; 17 | this.handleUpdate = this.handleUpdate.bind(this); 18 | } 19 | 20 | handleUpdate(values) { 21 | const { shadowTop, shadowBottom } = this.refs; 22 | const { scrollTop, scrollHeight, clientHeight } = values; 23 | const shadowTopOpacity = (1 / 20) * Math.min(scrollTop, 20); 24 | const bottomScrollTop = scrollHeight - clientHeight; 25 | const shadowBottomOpacity = 26 | (1 / 20) * (bottomScrollTop - Math.max(scrollTop, bottomScrollTop - 20)); 27 | css(shadowTop, { opacity: shadowTopOpacity }); 28 | css(shadowBottom, { opacity: shadowBottomOpacity }); 29 | } 30 | 31 | render() { 32 | const { style, ...props } = this.props; 33 | const containerStyle: CSSProperties = { 34 | ...style, 35 | position: 'relative', 36 | }; 37 | const shadowTopStyle: CSSProperties = { 38 | position: 'absolute', 39 | top: 0, 40 | left: 0, 41 | right: 0, 42 | height: 10, 43 | background: 'linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%)', 44 | }; 45 | const shadowBottomStyle: CSSProperties = { 46 | position: 'absolute', 47 | bottom: 0, 48 | left: 0, 49 | right: 0, 50 | height: 10, 51 | background: 'linear-gradient(to top, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%)', 52 | }; 53 | return ( 54 |
55 | 56 |
57 |
58 |
59 | ); 60 | } 61 | } 62 | 63 | export default ShadowScrollbars; 64 | -------------------------------------------------------------------------------- /test/Scrollbars/hideTracks.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | 4 | export default function createTests(scrollbarWidth) { 5 | describe('hide tracks', () => { 6 | let node; 7 | beforeEach(() => { 8 | node = document.createElement('div'); 9 | document.body.appendChild(node); 10 | }); 11 | afterEach(() => { 12 | unmountComponentAtNode(node); 13 | document.body.removeChild(node); 14 | }); 15 | 16 | describe('when native scrollbars have a width', () => { 17 | if (!scrollbarWidth) return; 18 | describe('when content is greater than wrapper', () => { 19 | it('should show tracks', (done) => { 20 | render( 21 | 22 |
23 | , 24 | node, 25 | function callback() { 26 | setTimeout(() => { 27 | const { trackHorizontal, trackVertical } = this; 28 | expect(trackHorizontal.style.visibility).toEqual('visible'); 29 | expect(trackVertical.style.visibility).toEqual('visible'); 30 | done(); 31 | }, 100); 32 | }, 33 | ); 34 | }); 35 | }); 36 | describe('when content is smaller than wrapper', () => { 37 | it('should hide tracks', (done) => { 38 | render( 39 | 40 |
41 | , 42 | node, 43 | function callback() { 44 | setTimeout(() => { 45 | const { trackHorizontal, trackVertical } = this; 46 | expect(trackHorizontal.style.visibility).toEqual('hidden'); 47 | expect(trackVertical.style.visibility).toEqual('hidden'); 48 | done(); 49 | }, 100); 50 | }, 51 | ); 52 | }); 53 | }); 54 | }); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /test/Scrollbars/clickTrack.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | import simulant from 'simulant'; 4 | 5 | export default function createTests(scrollbarWidth) { 6 | // Not for mobile environment 7 | if (!scrollbarWidth) return; 8 | 9 | let node; 10 | beforeEach(() => { 11 | node = document.createElement('div'); 12 | document.body.appendChild(node); 13 | }); 14 | afterEach(() => { 15 | unmountComponentAtNode(node); 16 | document.body.removeChild(node); 17 | }); 18 | 19 | describe('when clicking on horizontal track', () => { 20 | it('should scroll to the respective position', (done) => { 21 | render( 22 | 23 |
24 | , 25 | node, 26 | function callback() { 27 | setTimeout(() => { 28 | const { view, trackHorizontal: bar } = this; 29 | const { left, width } = bar.getBoundingClientRect(); 30 | simulant.fire(bar, 'mousedown', { 31 | target: bar, 32 | clientX: left + width / 2, 33 | }); 34 | expect(view.scrollLeft).toEqual(50); 35 | done(); 36 | }, 100); 37 | }, 38 | ); 39 | }); 40 | }); 41 | 42 | describe('when clicking on vertical track', () => { 43 | it('should scroll to the respective position', (done) => { 44 | render( 45 | 46 |
47 | , 48 | node, 49 | function callback() { 50 | setTimeout(() => { 51 | const { view, trackVertical: bar } = this; 52 | const { top, height } = bar.getBoundingClientRect(); 53 | simulant.fire(bar, 'mousedown', { 54 | target: bar, 55 | clientY: top + height / 2, 56 | }); 57 | expect(view.scrollTop).toEqual(50); 58 | done(); 59 | }, 100); 60 | }, 61 | ); 62 | }); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /src/Scrollbars/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, HTMLAttributes } from 'react'; 2 | import * as React from 'react'; 3 | 4 | export interface ScrollbarsProps { 5 | children?: React.ReactNode; 6 | autoHeight: boolean; 7 | autoHeightMax: number | string; 8 | autoHeightMin: number | string; 9 | autoHide: boolean; 10 | autoHideDuration: number; 11 | autoHideTimeout: number; 12 | /* class applied to the root element */ 13 | className?: string; 14 | classes?: Partial; 15 | disableDefaultStyles: boolean; 16 | hideTracksWhenNotNeeded?: boolean; 17 | id?: string; 18 | onScroll?: (e: React.UIEvent) => void; 19 | onScrollFrame?: (values: ScrollValues) => void; 20 | onScrollStart?: () => void; 21 | onScrollStop?: () => void; 22 | onUpdate?: (values: ScrollValues) => void; 23 | renderThumbHorizontal: CustomRenderer; 24 | renderThumbVertical: CustomRenderer; 25 | renderTrackHorizontal: CustomRenderer; 26 | renderTrackVertical: CustomRenderer; 27 | renderView: CustomRenderer; 28 | style?: React.CSSProperties; 29 | tagName: string; 30 | thumbMinSize: number; 31 | thumbSize?: number; 32 | universal: boolean; 33 | } 34 | 35 | export interface ScrollValues { 36 | left: number; 37 | top: number; 38 | scrollLeft: number; 39 | scrollTop: number; 40 | scrollWidth: number; 41 | scrollHeight: number; 42 | clientWidth: number; 43 | clientHeight: number; 44 | } 45 | 46 | export interface StyleClasses { 47 | root: string; 48 | view: string; 49 | trackHorizontal: string; 50 | thumbHorizontal: string; 51 | trackVertical: string; 52 | thumbVertical: string; 53 | } 54 | 55 | export type StyleKeys = 56 | | 'containerStyleDefault' 57 | | 'containerStyleAutoHeight' 58 | | 'viewStyleDefault' 59 | | 'viewStyleAutoHeight' 60 | | 'viewStyleUniversalInitial' 61 | | 'trackHorizontalStyleDefault' 62 | | 'trackVerticalStyleDefault' 63 | | 'thumbStyleDefault' 64 | | 'disableSelectStyle' 65 | | 'disableSelectStyleReset'; 66 | 67 | export interface CustomRenderProps extends HTMLAttributes { 68 | style: CSSProperties; 69 | className: string; 70 | } 71 | 72 | export interface CustomRenderer { 73 | (props: CustomRenderProps): JSX.Element; 74 | } 75 | -------------------------------------------------------------------------------- /test/Scrollbars/onUpdate.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | 4 | export default function createTests() { 5 | let node; 6 | beforeEach(() => { 7 | node = document.createElement('div'); 8 | document.body.appendChild(node); 9 | }); 10 | afterEach(() => { 11 | unmountComponentAtNode(node); 12 | document.body.removeChild(node); 13 | }); 14 | 15 | describe('onUpdate', () => { 16 | describe('when scrolling x-axis', () => { 17 | it('should call `onUpdate`', (done) => { 18 | const spy = createSpy(); 19 | render( 20 | 21 |
22 | , 23 | node, 24 | function callback() { 25 | this.scrollLeft(50); 26 | setTimeout(() => { 27 | expect(spy.calls.length).toEqual(1); 28 | done(); 29 | }, 100); 30 | }, 31 | ); 32 | }); 33 | }); 34 | describe('when scrolling y-axis', () => { 35 | it('should call `onUpdate`', (done) => { 36 | const spy = createSpy(); 37 | render( 38 | 39 |
40 | , 41 | node, 42 | function callback() { 43 | this.scrollTop(50); 44 | setTimeout(() => { 45 | expect(spy.calls.length).toEqual(1); 46 | done(); 47 | }, 100); 48 | }, 49 | ); 50 | }); 51 | }); 52 | 53 | describe('when resizing window', () => { 54 | it('should call onUpdate', (done) => { 55 | const spy = createSpy(); 56 | render( 57 | 58 |
59 | , 60 | node, 61 | function callback() { 62 | setTimeout(() => { 63 | expect(spy.calls.length).toEqual(1); 64 | done(); 65 | }, 100); 66 | }, 67 | ); 68 | }); 69 | }); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rc-scrollbars", 3 | "version": "1.1.6", 4 | "description": "React scrollbars component", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib", 8 | "dev:lib": "tsc -w", 9 | "dev:docs": "dumi dev", 10 | "dev": "concurrently -k -p \"[{name}]\" -n \"LIB,DOCS\" -c \"bgMagenta.bold,bgGreen.bold\" \"npm run dev:lib\" \"npm run dev:docs\"", 11 | "build:lib": "tsc", 12 | "build:docs": "dumi build", 13 | "lint": "eslint src test", 14 | "test": "cross-env NODE_ENV=test karma start", 15 | "test:watch": "cross-env NODE_ENV=test karma start --auto-watch --no-single-run", 16 | "test:cov": "cross-env NODE_ENV=test COVERAGE=true karma start --single-run", 17 | "prepublish": "npm run lint && npm run clean && npm run build:lib" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/sakhnyuk/rc-scrollbars.git", 22 | "branch": "main" 23 | }, 24 | "keywords": [ 25 | "scroll", 26 | "scroller", 27 | "scrollbars", 28 | "react-component", 29 | "react", 30 | "custom", 31 | "rc-scrollbars" 32 | ], 33 | "author": "Mikhail Sakhniuk", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/sakhnyuk/rc-scrollbars/issues" 37 | }, 38 | "homepage": "https://github.com/sakhnyuk/rc-scrollbars", 39 | "devDependencies": { 40 | "@types/raf": "^3.4.0", 41 | "@types/react": "^18.0.9", 42 | "@types/react-dom": "^18.0.4", 43 | "@typescript-eslint/eslint-plugin": "^5.23.0", 44 | "@typescript-eslint/parser": "^5.23.0", 45 | "concurrently": "^7.1.0", 46 | "cross-env": "^7.0.3", 47 | "dumi": "^1.1.40", 48 | "eslint": "^8.15.0", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-config-react-app": "^7.0.1", 51 | "eslint-plugin-flowtype": "^8.0.3", 52 | "eslint-plugin-import": "^2.26.0", 53 | "eslint-plugin-jsx-a11y": "^6.5.1", 54 | "eslint-plugin-prettier": "^4.0.0", 55 | "eslint-plugin-react": "^7.29.4", 56 | "eslint-plugin-react-hooks": "^4.5.0", 57 | "expect": "^26.6.2", 58 | "fork-ts-checker-webpack-plugin": "^7.2.11", 59 | "glob": "^7.1.6", 60 | "istanbul-instrumenter-loader": "^3.0.1", 61 | "karma": "^6.3.3", 62 | "karma-chrome-launcher": "^3.1.0", 63 | "karma-cli": "^2.0.0", 64 | "karma-coverage": "^2.0.3", 65 | "karma-mocha": "^2.0.1", 66 | "karma-mocha-reporter": "^2.2.5", 67 | "karma-sourcemap-loader": "^0.3.8", 68 | "karma-webpack": "^4.0.2", 69 | "mocha": "^8.2.1", 70 | "prettier": "^2.6.2", 71 | "react": "^18.1.0", 72 | "react-dom": "^18.1.0", 73 | "rebound": "^0.1.0", 74 | "rimraf": "^3.0.2", 75 | "simulant": "^0.2.2", 76 | "typescript": "^4.6.4" 77 | }, 78 | "peerDependencies": { 79 | "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", 80 | "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 81 | }, 82 | "dependencies": { 83 | "dom-css": "^2.1.0", 84 | "raf": "^3.4.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | order: 2 4 | --- 5 | 6 | # Usage 7 | 8 | ## Default Scrollbars 9 | 10 | The `` component works out of the box with some default styles. The only thing you need to care about is that the component has a `width` and `height`: 11 | 12 | ```jsx | pure 13 | import React from 'react'; 14 | import { Scrollbars } from 'rc-scrollbars'; 15 | import { Lorem } from './components/Lorem'; 16 | 17 | export default () => ( 18 | 19 | 20 | 21 | ); 22 | ``` 23 | 24 | Also don't forget to set the `viewport` meta tag, if you want to **support mobile devices** 25 | 26 | ```html 27 | 31 | ``` 32 | 33 | ## Events 34 | 35 | There are several events you can listen to: 36 | 37 | ```jsx | pure 38 | import { Scrollbars } from 'rc-scrollbars'; 39 | 40 | class App extends Component { 41 | render() { 42 | return ( 43 | 55 |

Some great content...

56 |
57 | ); 58 | } 59 | } 60 | ``` 61 | 62 | ## Auto-hide 63 | 64 | You can activate auto-hide by setting the `autoHide` property. 65 | 66 | Check out [demo](/demo#auto-hide) 67 | 68 | ```jsx | pure 69 | import { Scrollbars } from 'rc-scrollbars'; 70 | 71 | class App extends Component { 72 | render() { 73 | return ( 74 | 82 |

Some great content...

83 |
84 | ); 85 | } 86 | } 87 | ``` 88 | 89 | ## Auto-height 90 | 91 | You can activate auto-height by setting the `autoHeight` property. 92 | 93 | ```jsx | pure 94 | import React from 'react'; 95 | import { Scrollbars } from 'rc-scrollbars'; 96 | import { Lorem } from './components/Lorem'; 97 | 98 | export default () => { 99 | return ( 100 | 101 | 102 | 103 | ); 104 | }; 105 | ``` 106 | 107 | ## Universal rendering 108 | 109 | If your app runs on both client and server, activate the `universal` mode. This will ensure that the initial markup on client and server are the same: 110 | 111 | ```jsx | pure 112 | import { Scrollbars } from 'rc-scrollbars'; 113 | 114 | export const Component = () => { 115 | return ( 116 | 117 |

Some great content...

118 |
119 | ); 120 | }; 121 | ``` 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rc-scrollbars 2 | ========================= 3 | rejuvenated project of react-custom-scrollbars 4 | 5 | [![npm](https://img.shields.io/badge/npm-rc--scrollbars-brightgreen.svg?style=flat-square)](https://www.npmjs.com/package/rc-scrollbars) 6 | [![npm version](https://img.shields.io/npm/v/rc-scrollbars.svg?style=flat-square)](https://www.npmjs.com/package/rc-scrollbars) 7 | [![npm downloads](https://img.shields.io/npm/dm/rc-scrollbars.svg?style=flat-square)](https://www.npmjs.com/package/rc-scrollbars) 8 | 9 | 10 | * frictionless native browser scrolling 11 | * native scrollbars for mobile devices 12 | * [fully customizable](https://github.com/sakhnyuk/rc-scrollbars/blob/main/docs/customization.md) 13 | * [auto hide](https://github.com/sakhnyuk/rc-scrollbars/blob/main/docs/usage.md#auto-hide) 14 | * [auto height](https://github.com/sakhnyuk/rc-scrollbars/blob/main/docs/usage.md#auto-height) 15 | * [universal](https://github.com/sakhnyuk/rc-scrollbars/blob/main/docs/usage.md#universal-rendering) (runs on client & server) 16 | * `requestAnimationFrame` for 60fps 17 | * no extra stylesheets 18 | * well tested, 100% code coverage 19 | 20 | **[Documentation](https://rc-scrollbars.vercel.app) · [Demos](https://rc-scrollbars.vercel.app/demo)** 21 | 22 | ## Installation 23 | ```bash 24 | npm install rc-scrollbars --save 25 | 26 | # OR 27 | 28 | yarn add rc-scrollbars 29 | ``` 30 | 31 | This assumes that you’re using [npm](http://npmjs.com/) package manager with a module bundler like [Webpack](http://webpack.github.io) or [Browserify](http://browserify.org/) to consume [CommonJS modules](http://webpack.github.io/docs/commonjs.html). 32 | 33 | ## Usage 34 | 35 | This is the minimal configuration. [Check out the Documentation for advanced usage](https://rc-scrollbars.vercel.app/usage). 36 | 37 | ```javascript 38 | import { Scrollbars } from 'rc-scrollbars'; 39 | 40 | class App extends Component { 41 | render() { 42 | return ( 43 | 44 |

Some great content...

45 |
46 | ); 47 | } 48 | } 49 | ``` 50 | 51 | The `` component is completely customizable. Check out the following code: 52 | 53 | ```javascript 54 | import { Scrollbars } from 'rc-scrollbars'; 55 | 56 | class CustomScrollbars extends Component { 57 | render() { 58 | return ( 59 | 79 | ); 80 | } 81 | } 82 | ``` 83 | 84 | All properties are documented in the [API docs](https://rc-scrollbars.vercel.app/api) 85 | 86 | ## Run project locally 87 | 88 | Run the simple example: 89 | ```bash 90 | # Make sure that you've installed the dependencies 91 | yarn 92 | # Run tsc of Scrollbars in watch mode and the documentation project in dev env 93 | yarn dev 94 | ``` 95 | 96 | ## License 97 | 98 | MIT 99 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | order: 4 4 | --- 5 | 6 | # API 7 | ## `` 8 | 9 | | Property | Description | Type | Default | 10 | | --- | --- | --- | --- | 11 | | autoHeight | Enable auto-height mode. When `true` container grows with content | `boolean` | `false` | 12 | | autoHeightMax | Set a minimum height for auto-height mode. Ignoring if **autoHeight** is `false` | `number` | 0 | 13 | | autoHeightMin | Set a maximum height for auto-height mode. Ignoring if **autoHeight** is `false`| `number` | 200 | 14 | | autoHide | Enable auto-hide mode. When `true` tracks will hide automatically and are only visible while scrolling. | `boolean` | `false` | 15 | | autoHideDuration | Duration for hide animation in ms. | `number` | 200 | 16 | | autoHideTimeout | Hide delay in ms. | `number` | 1000 | 17 | | classes | extra custom className/s to any of the subcomponents | Partial | `{}` 18 | | className | className for the root component | `string` | `undefined` 19 | | disableDefaultStyles | removes basic styling to ease visual customization | `boolean` | `false` 20 | | hideTracksWhenNotNeeded | Hide tracks (`visibility: hidden`) when content does not overflow container. | `boolean` | `false` | 21 | | id | The `id` to apply to the root element | `string` | `undefined` | 22 | | onScroll | Event handler | `(e: React.UIEvent) => void` | `undefined` | 23 | | onScrollFrame | Runs inside the animation frame. Type of `ScrollValues` you can check below | `(values: ScrollValues) => void` | `undefined` | 24 | | onScrollStart | Called when scrolling starts | `() => void` | `undefined` | 25 | | onScrollStop | Called when scrolling stops | `() => void` | `undefined` | 26 | | onUpdate | Called when ever the component is updated. Runs inside the animation frame | `(values: ScrollValues) => void` | `undefined` | 27 | | renderThumbHorizontal | Horizontal thumb element | `(props: HTMLAttributes) => JSX.Element` | `undefined` | 28 | | renderThumbVertical | Vertical thumb element | `(props: HTMLAttributes) => JSX.Element` | `undefined` | 29 | | renderTrackHorizontal | Horizontal track element | `(props: HTMLAttributes) => JSX.Element` | `undefined` | 30 | | renderTrackVertical | Vertical track element | `(props: HTMLAttributes) => JSX.Element` | `undefined` | 31 | | renderView | The element your content will be rendered in | `(props: HTMLAttributes) => JSX.Element` | `undefined` | 32 | | thumbMinSize | The element your content will be rendered in | `number` | 30 | 33 | | thumbSize | The element your content will be rendered in | `number or undefined` | `undefined` | 34 | | universal | Enable universal rendering (SSR). [Learn how to use universal rendering](/usage#universal-rendering) | `boolean` | `false` | 35 | 36 | ### ScrollValues 37 | ```typescript 38 | export interface ScrollValues { 39 | left: number; // scrollLeft progess, from 0 to 1 40 | top: number; // scrollTop progess, from 0 to 1 41 | scrollLeft: number; // Native scrollLeft 42 | scrollTop: number; // Native scrollTop 43 | scrollWidth: number; // Width of the view 44 | scrollHeight: number; // Native scrollHeight 45 | clientWidth: number; // Width of the view 46 | clientHeight: number; // Height of the view 47 | } 48 | ``` 49 | 50 | ### Methods 51 | 52 | - `scrollTop(top = 0)`: scroll to the top value 53 | - `scrollLeft(left = 0)`: scroll to the left value 54 | - `scrollToTop()`: scroll to top 55 | - `scrollToBottom()`: scroll to bottom 56 | - `scrollToLeft()`: scroll to left 57 | - `scrollToRight()`: scroll to right 58 | - `getScrollLeft()`: get scrollLeft value 59 | - `getScrollTop()`: get scrollTop value 60 | - `getScrollWidth()`: get scrollWidth value 61 | - `getScrollHeight()`: get scrollHeight value 62 | - `getClientWidth()`: get view client width 63 | - `getClientHeight()`: get view client height 64 | - `getValues()`: get an object with values about the current position. 65 | -------------------------------------------------------------------------------- /test/Scrollbars/dragThumb.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | import simulant from 'simulant'; 4 | 5 | export default function createTests(scrollbarWidth) { 6 | // Not for mobile environment 7 | if (!scrollbarWidth) return; 8 | 9 | let node; 10 | beforeEach(() => { 11 | node = document.createElement('div'); 12 | document.body.appendChild(node); 13 | }); 14 | afterEach(() => { 15 | unmountComponentAtNode(node); 16 | document.body.removeChild(node); 17 | }); 18 | describe('when dragging horizontal thumb', () => { 19 | it('should scroll to the respective position', (done) => { 20 | render( 21 | 22 |
23 | , 24 | node, 25 | function callback() { 26 | setTimeout(() => { 27 | const { view, thumbHorizontal: thumb } = this; 28 | const { left } = thumb.getBoundingClientRect(); 29 | simulant.fire(thumb, 'mousedown', { 30 | target: thumb, 31 | clientX: left + 1, 32 | }); 33 | simulant.fire(document, 'mousemove', { 34 | clientX: left + 100, 35 | }); 36 | simulant.fire(document, 'mouseup'); 37 | expect(view.scrollLeft).toEqual(100); 38 | done(); 39 | }, 100); 40 | }, 41 | ); 42 | }); 43 | 44 | it('should disable selection', (done) => { 45 | render( 46 | 47 |
48 | , 49 | node, 50 | function callback() { 51 | setTimeout(() => { 52 | const { thumbHorizontal: thumb } = this; 53 | const { left } = thumb.getBoundingClientRect(); 54 | simulant.fire(thumb, 'mousedown', { 55 | target: thumb, 56 | clientX: left + 1, 57 | }); 58 | expect(document.body.style.webkitUserSelect).toEqual('none'); 59 | simulant.fire(document, 'mouseup'); 60 | expect(document.body.style.webkitUserSelect).toEqual(''); 61 | done(); 62 | }, 100); 63 | }, 64 | ); 65 | }); 66 | }); 67 | 68 | describe('when dragging vertical thumb', () => { 69 | it('should scroll to the respective position', (done) => { 70 | render( 71 | 72 |
73 | , 74 | node, 75 | function callback() { 76 | setTimeout(() => { 77 | const { view, thumbVertical: thumb } = this; 78 | const { top } = thumb.getBoundingClientRect(); 79 | simulant.fire(thumb, 'mousedown', { 80 | target: thumb, 81 | clientY: top + 1, 82 | }); 83 | simulant.fire(document, 'mousemove', { 84 | clientY: top + 100, 85 | }); 86 | simulant.fire(document, 'mouseup'); 87 | expect(view.scrollTop).toEqual(100); 88 | done(); 89 | }, 100); 90 | }, 91 | ); 92 | }); 93 | 94 | it('should disable selection', (done) => { 95 | render( 96 | 97 |
98 | , 99 | node, 100 | function callback() { 101 | setTimeout(() => { 102 | const { thumbVertical: thumb } = this; 103 | const { top } = thumb.getBoundingClientRect(); 104 | simulant.fire(thumb, 'mousedown', { 105 | target: thumb, 106 | clientY: top + 1, 107 | }); 108 | expect(document.body.style.webkitUserSelect).toEqual('none'); 109 | simulant.fire(document, 'mouseup'); 110 | expect(document.body.style.webkitUserSelect).toEqual(''); 111 | done(); 112 | }, 100); 113 | }, 114 | ); 115 | }); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | order: 1 4 | --- 5 | 6 | # rc-scrollbars 7 | 8 | React scrollbars component. 9 | 10 | `rc-scrollbars` is rejuvenated project of react-custom-scrollbars 11 | 12 | [![npm](https://img.shields.io/badge/npm-rc--scrollbars-brightgreen.svg?style=flat-square)](https://www.npmjs.com/package/rc-scrollbars) 13 | [![npm version](https://img.shields.io/npm/v/rc-scrollbars.svg?style=flat-square)](https://www.npmjs.com/package/rc-scrollbars) 14 | [![npm downloads](https://img.shields.io/npm/dm/rc-scrollbars.svg?style=flat-square)](https://www.npmjs.com/package/rc-scrollbars) 15 | 16 | * frictionless native browser scrolling 17 | * native scrollbars for mobile devices 18 | * [fully customizable](/customization) 19 | * [auto hide](/usage#auto-hide) 20 | * [auto height](/usage#auto-height) 21 | * [universal](/usage#universal-rendering) (runs on client & server) 22 | * `requestAnimationFrame` for 60fps 23 | * no extra stylesheets 24 | * well tested, 100% code coverage 25 | 26 | #### **[Demos](/demo) · [API](/api) · [GitHub](https://github.com/sakhnyuk/rc-scrollbars)** 27 | 28 | ```jsx 29 | /** 30 | * title: Basic DEMO of scrollbar 31 | * hideActions: ['CSB', 'EXTERNAL'] 32 | */ 33 | import React from 'react'; 34 | import { Scrollbars } from 'rc-scrollbars'; 35 | import { Lorem } from './components/Lorem'; 36 | 37 | export default () => ( 38 | 39 | 40 | 41 | ); 42 | ``` 43 | 44 | ## Installation 45 | ```bash 46 | npm install rc-scrollbars --save 47 | 48 | # OR 49 | 50 | yarn add rc-scrollbars 51 | ``` 52 | 53 | This assumes that you’re using [npm](http://npmjs.com/) package manager with a module bundler like [Webpack](http://webpack.github.io) or [Browserify](http://browserify.org/) to consume [CommonJS modules](http://webpack.github.io/docs/commonjs.html). 54 | 55 | ## MacOS scrollbars explained 56 | 57 | ![scrollbar-macos](/scrollbar-macos.png) 58 | 59 | MacOS have 2 options of scrollbars visibility: 60 | - **Automatically based on mouse and trackpad** and **When scrolling** - Scrollbar thumb visible only while scrolling. Browsers don't add scrollbar blocks and only scrollbar thumbs placed over scroll block. **rc-scrollbars v1 don't render scroll tracks and thumbs** 61 | - **Always** - Show scrollbar always. In that option, browsers add system scrollbars like on Windows and Linux. **rc-scrollbars** working as expected. 62 | 63 | ## Usage 64 | 65 | This is the minimal configuration. [Check out the **Usage** page for advanced examples](/usage). 66 | 67 | ```javascript 68 | import { Scrollbars } from 'rc-scrollbars'; 69 | 70 | class App extends Component { 71 | render() { 72 | return ( 73 | 74 |

Some great content...

75 |
76 | ); 77 | } 78 | } 79 | ``` 80 | 81 | The `` component is completely customizable. Check out the following code: 82 | 83 | ```javascript 84 | import { Scrollbars } from 'rc-scrollbars'; 85 | 86 | class CustomScrollbars extends Component { 87 | render() { 88 | return ( 89 | 109 | ); 110 | } 111 | } 112 | ``` 113 | 114 | All properties are documented in the [API docs](/api) 115 | 116 | ## Run project locally 117 | 118 | ```bash 119 | # Make sure that you've installed the dependencies 120 | yarn 121 | # Run tsc of Scrollbars in watch mode and the documentation project in dev env 122 | yarn dev 123 | ``` 124 | 125 | ## License 126 | 127 | MIT 128 | -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Demo 3 | order: 5 4 | --- 5 | 6 | # Demo of rc-scrollbars 7 | 8 | ## Default usage 9 | 10 | ```jsx 11 | /** 12 | * title: Default vertical content 13 | * desc: Default usage of rc-scrollbars with vertical scroll 14 | * hideActions: ['CSB'] 15 | */ 16 | import React from 'react'; 17 | import { Scrollbars } from 'rc-scrollbars'; 18 | import { Lorem } from './components/Lorem'; 19 | 20 | export default () => ( 21 | 22 | 23 | 24 | ); 25 | ``` 26 | 27 | ```jsx 28 | /** 29 | * title: Default hozintal content 30 | * desc: Default usage of rc-scrollbars with horizontal and vertical scroll 31 | * hideActions: ['CSB'] 32 | */ 33 | import React from 'react'; 34 | import { Scrollbars } from 'rc-scrollbars'; 35 | import { Lorem } from './components/Lorem'; 36 | 37 | export default () => ( 38 | 39 |
40 | 41 |
42 |
43 | ); 44 | ``` 45 | 46 | ## Auto-hide 47 | 48 | ```jsx 49 | /** 50 | * hideActions: ['CSB'] 51 | */ 52 | import React from 'react'; 53 | import { Scrollbars } from 'rc-scrollbars'; 54 | import { Lorem } from './components/Lorem'; 55 | 56 | export default class App extends React.Component { 57 | render() { 58 | return ( 59 | 60 | 61 | 62 | ); 63 | } 64 | } 65 | ``` 66 | 67 | # Scrollbar customization 68 | 69 | ## Colored Scrollbar view 70 | 71 | ```jsx 72 | /** 73 | * title: Colored Scrollbar 74 | * desc: Example type customization of scrollbar and view 75 | * hideActions: ['CSB'] 76 | */ 77 | import React from 'react'; 78 | import ColoredScrollbars from './components/ColoredScrollbars'; 79 | import { Lorem } from './components/Lorem'; 80 | 81 | export default () => ( 82 | 83 | 84 | 85 | ); 86 | ``` 87 | 88 | ## Scrollbar View with shadow 89 | 90 | ```jsx 91 | /** 92 | * title: Shadow view 93 | * hideActions: ['CSB'] 94 | */ 95 | import React from 'react'; 96 | import { Scrollbars } from 'rc-scrollbars'; 97 | import { Lorem } from './components/Lorem'; 98 | import ShadowScrollbars from './components/ShadowScrollbars/ShadowScrollbars'; 99 | 100 | export default class App extends React.Component { 101 | render() { 102 | return ( 103 | 104 | 105 | 106 | ); 107 | } 108 | } 109 | ``` 110 | 111 | ## Custom scrollbar 112 | 113 | ```tsx 114 | /** 115 | * title: Styled scrollbar 116 | * hideActions: ['CSB'] 117 | */ 118 | import React from 'react'; 119 | import { Scrollbars } from 'rc-scrollbars'; 120 | import { Lorem } from './components/Lorem'; 121 | 122 | export default class App extends React.Component { 123 | thumbVertical({ style, ...props }: HTMLAttributes) { 124 | const finalStyle = { 125 | ...style, 126 | cursor: 'pointer', 127 | backgroundColor: 'rgba(0,255,0,.6)', 128 | }; 129 | 130 | return
; 131 | } 132 | 133 | thumbHorizontal({ style, ...props }: HTMLAttributes) { 134 | const finalStyle = { 135 | ...style, 136 | cursor: 'pointer', 137 | backgroundColor: 'rgba(255,0,0,.6)', 138 | }; 139 | 140 | return
; 141 | } 142 | 143 | render() { 144 | return ( 145 | 150 |
151 | 152 |
153 |
154 | ); 155 | } 156 | } 157 | ``` 158 | 159 | ## With SpringSystem by `rebound` 160 | 161 | ```jsx 162 | /** 163 | * title: Spring Scrollbar 164 | * hideActions: ['CSB'] 165 | */ 166 | import React from 'react'; 167 | import SpringScrollbarsExample from './components/SpringScrollbars/SpringScrollbarsExample'; 168 | 169 | export default class App extends React.Component { 170 | render() { 171 | return ; 172 | } 173 | } 174 | ``` 175 | -------------------------------------------------------------------------------- /test/Scrollbars/gettersSetters.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | 4 | export default function createTests(scrollbarWidth, envScrollbarWidth) { 5 | let node; 6 | beforeEach(() => { 7 | node = document.createElement('div'); 8 | document.body.appendChild(node); 9 | }); 10 | afterEach(() => { 11 | unmountComponentAtNode(node); 12 | document.body.removeChild(node); 13 | }); 14 | 15 | describe('getters', () => { 16 | function renderScrollbars(callback) { 17 | render( 18 | 19 |
20 | , 21 | node, 22 | callback, 23 | ); 24 | } 25 | describe('getScrollLeft', () => { 26 | it('should return scrollLeft', (done) => { 27 | renderScrollbars(function callback() { 28 | this.scrollLeft(50); 29 | expect(this.getScrollLeft()).toEqual(50); 30 | done(); 31 | }); 32 | }); 33 | }); 34 | describe('getScrollTop', () => { 35 | it('should return scrollTop', (done) => { 36 | renderScrollbars(function callback() { 37 | this.scrollTop(50); 38 | expect(this.getScrollTop()).toEqual(50); 39 | done(); 40 | }); 41 | }); 42 | }); 43 | describe('getScrollWidth', () => { 44 | it('should return scrollWidth', (done) => { 45 | renderScrollbars(function callback() { 46 | expect(this.getScrollWidth()).toEqual(200); 47 | done(); 48 | }); 49 | }); 50 | }); 51 | describe('getScrollHeight', () => { 52 | it('should return scrollHeight', (done) => { 53 | renderScrollbars(function callback() { 54 | expect(this.getScrollHeight()).toEqual(200); 55 | done(); 56 | }); 57 | }); 58 | }); 59 | describe('getClientWidth', () => { 60 | it('should return scrollWidth', (done) => { 61 | renderScrollbars(function callback() { 62 | expect(this.getClientWidth()).toEqual(100 + (scrollbarWidth - envScrollbarWidth)); 63 | done(); 64 | }); 65 | }); 66 | }); 67 | describe('getClientHeight', () => { 68 | it('should return scrollHeight', (done) => { 69 | renderScrollbars(function callback() { 70 | expect(this.getClientHeight()).toEqual(100 + (scrollbarWidth - envScrollbarWidth)); 71 | done(); 72 | }); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('setters', () => { 78 | function renderScrollbars(callback) { 79 | render( 80 | 81 |
82 | , 83 | node, 84 | callback, 85 | ); 86 | } 87 | describe('scrollLeft/scrollToLeft', () => { 88 | it('should scroll to given left value', (done) => { 89 | renderScrollbars(function callback() { 90 | this.scrollLeft(50); 91 | expect(this.getScrollLeft()).toEqual(50); 92 | this.scrollToLeft(); 93 | expect(this.getScrollLeft()).toEqual(0); 94 | this.scrollLeft(50); 95 | this.scrollLeft(); 96 | expect(this.getScrollLeft()).toEqual(0); 97 | done(); 98 | }); 99 | }); 100 | }); 101 | describe('scrollTop/scrollToTop', () => { 102 | it('should scroll to given top value', (done) => { 103 | renderScrollbars(function callback() { 104 | this.scrollTop(50); 105 | expect(this.getScrollTop()).toEqual(50); 106 | this.scrollToTop(); 107 | expect(this.getScrollTop()).toEqual(0); 108 | this.scrollTop(50); 109 | this.scrollTop(); 110 | expect(this.getScrollTop()).toEqual(0); 111 | done(); 112 | }); 113 | }); 114 | }); 115 | describe('scrollToRight', () => { 116 | it('should scroll to right', (done) => { 117 | renderScrollbars(function callback() { 118 | this.scrollToRight(); 119 | expect(this.getScrollLeft()).toEqual(100 + (envScrollbarWidth - scrollbarWidth)); 120 | done(); 121 | }); 122 | }); 123 | }); 124 | describe('scrollToBottom', () => { 125 | it('should scroll to bottom', (done) => { 126 | renderScrollbars(function callback() { 127 | this.scrollToBottom(); 128 | expect(this.getScrollTop()).toEqual(100 + (envScrollbarWidth - scrollbarWidth)); 129 | done(); 130 | }); 131 | }); 132 | }); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /test/Scrollbars/universal.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | 4 | export default function createTests(scrollbarWidth) { 5 | let node; 6 | beforeEach(() => { 7 | node = document.createElement('div'); 8 | document.body.appendChild(node); 9 | }); 10 | afterEach(() => { 11 | unmountComponentAtNode(node); 12 | document.body.removeChild(node); 13 | }); 14 | 15 | describe('universal', () => { 16 | describe('default', () => { 17 | describe('when rendered', () => { 18 | it('should hide overflow', (done) => { 19 | class ScrollbarsTest extends Scrollbars { 20 | // Override componentDidMount, so we can check, how the markup 21 | // looks like on the first rendering 22 | componentDidMount() {} 23 | } 24 | render( 25 | 26 |
27 | , 28 | node, 29 | function callback() { 30 | const { view, trackHorizontal, trackVertical } = this; 31 | expect(view.style.position).toEqual('absolute'); 32 | expect(view.style.overflow).toEqual('hidden'); 33 | expect(view.style.top).toEqual('0px'); 34 | expect(view.style.bottom).toEqual('0px'); 35 | expect(view.style.left).toEqual('0px'); 36 | expect(view.style.right).toEqual('0px'); 37 | expect(view.style.marginBottom).toEqual('0px'); 38 | expect(view.style.marginRight).toEqual('0px'); 39 | expect(trackHorizontal.style.display).toEqual('none'); 40 | expect(trackVertical.style.display).toEqual('none'); 41 | done(); 42 | }, 43 | ); 44 | }); 45 | }); 46 | describe('when componentDidMount', () => { 47 | it('should rerender', (done) => { 48 | render( 49 | 50 |
51 | , 52 | node, 53 | function callback() { 54 | setTimeout(() => { 55 | const { view } = this; 56 | expect(view.style.overflow).toEqual('scroll'); 57 | expect(view.style.marginBottom).toEqual(`${-scrollbarWidth}px`); 58 | expect(view.style.marginRight).toEqual(`${-scrollbarWidth}px`); 59 | done(); 60 | }, 100); 61 | }, 62 | ); 63 | }); 64 | }); 65 | }); 66 | describe('when using autoHeight', () => { 67 | describe('when rendered', () => { 68 | it('should hide overflow', (done) => { 69 | class ScrollbarsTest extends Scrollbars { 70 | // Override componentDidMount, so we can check, how the markup 71 | // looks like on the first rendering 72 | componentDidMount() {} 73 | } 74 | render( 75 | 76 |
77 | , 78 | node, 79 | function callback() { 80 | const { view, trackHorizontal, trackVertical } = this; 81 | expect(view.style.position).toEqual('relative'); 82 | expect(view.style.overflow).toEqual('hidden'); 83 | expect(view.style.marginBottom).toEqual('0px'); 84 | expect(view.style.marginRight).toEqual('0px'); 85 | expect(view.style.minHeight).toEqual('0px'); 86 | expect(view.style.maxHeight).toEqual('100px'); 87 | expect(trackHorizontal.style.display).toEqual('none'); 88 | expect(trackVertical.style.display).toEqual('none'); 89 | done(); 90 | }, 91 | ); 92 | }); 93 | }); 94 | describe('when componentDidMount', () => { 95 | it('should rerender', (done) => { 96 | render( 97 | 98 |
99 | , 100 | node, 101 | function callback() { 102 | setTimeout(() => { 103 | const { view } = this; 104 | expect(view.style.overflow).toEqual('scroll'); 105 | expect(view.style.marginBottom).toEqual(`${-scrollbarWidth}px`); 106 | expect(view.style.marginRight).toEqual(`${-scrollbarWidth}px`); 107 | expect(view.style.minHeight).toEqual(`${scrollbarWidth}px`); 108 | expect(view.style.maxHeight).toEqual(`${100 + scrollbarWidth}px`); 109 | done(); 110 | }); 111 | }, 112 | ); 113 | }); 114 | }); 115 | }); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /docs/customization.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Customization 3 | order: 3 4 | --- 5 | 6 | # Customization 7 | 8 | The `` component consists of the following elements: 9 | 10 | - `root` The root element that covering scrolled view with track and thumb placed 11 | - `view` The element your content is rendered in 12 | - `trackHorizontal` The horizontal scrollbars track 13 | - `trackVertical` The vertical scrollbars track 14 | - `thumbHorizontal` The horizontal thumb 15 | - `thumbVertical` The vertical thumb 16 | 17 | ![scrollbar-components](/scrollbar-components.png) 18 | 19 | ## `rc-scrollbars` provide three ways to get your components styled 20 | 21 | ## Render props 22 | 23 | Each element can be **rendered individually** with a function that you pass to the component. 24 | 25 | Say, you want use your own `className` for each element: 26 | 27 | ```typescript jsx 28 | import { Scrollbars } from 'rc-scrollbars'; 29 | import './styles.css'; 30 | 31 | class CustomScrollbars extends Component { 32 | render() { 33 | return ( 34 |
} 36 | renderTrackVertical={(props) =>
} 37 | renderThumbHorizontal={(props) =>
} 38 | renderThumbVertical={(props) =>
} 39 | renderView={(props) =>
} 40 | > 41 | {this.props.children} 42 | 43 | ); 44 | } 45 | } 46 | 47 | class App extends Component { 48 | render() { 49 | return ( 50 | 51 |

Some great content...

52 |
53 | ); 54 | } 55 | } 56 | ``` 57 | 58 | **Important**: **You will always need to pass through the given props** for the respective element like in the example above: `
`. This is because we need to pass some default `styles` down to the element in order to make the component work. 59 | 60 | If you are working with **inline styles**, you could do something like this: 61 | 62 | ```jsx | pure 63 | import { Scrollbars } from 'rc-scrollbars'; 64 | 65 | const myOwnStyles = { backgroundColor: 'blue' }; 66 | 67 | class CustomScrollbars extends Component { 68 | render() { 69 | return ( 70 | ( 72 |
73 | )} 74 | > 75 | {this.props.children} 76 | 77 | ); 78 | } 79 | } 80 | ``` 81 | 82 | ## Default classNames 83 | 84 | For convenience, some 'marker' classes are provided for each of the subcomponents: 85 | 86 | `root`: 'rc-scrollbars-container' 87 | `view`: 'rc-scrollbars-view' 88 | `trackVertical`: 'rc-scrollbars-track rc-scrollbars-track-v' 89 | `trackHorizontal`: 'rc-scrollbars-track rc-scrollbars-track-h' 90 | `thumbVertical`: 'rc-scrollbars-thumb rc-scrollbars-thumb-v' 91 | `thumbHorizontal`: 'rc-scrollbars-thumb rc-scrollbars-thumb-h' 92 | 93 | There's very little 'beautifying' styles applied by default, however if you'd like to change the `background-color` of the **thumbs** or `border-radius` of the **tracks** you can easily disable their default styling by passing a single prop `disableDefaultStyles`. 94 | 95 | ## Classes prop 96 | 97 | You can pass `classes` prop and set your own className for every provided element 98 | 99 | ```jsx | pure 100 | import { Scrollbars } from 'rc-scrollbars'; 101 | import css from './styles.module.css'; 102 | 103 | const StyledScrollbars = ({ children }) => { 104 | return ( 105 | 115 | {children} 116 | 117 | ); 118 | }; 119 | ``` 120 | 121 | # Respond to scroll events 122 | 123 | If you want to change the appearance in respond to the scrolling position, you could do that like: 124 | 125 | ```javascript 126 | import { Scrollbars } from 'rc-scrollbars'; 127 | class CustomScrollbars extends Component { 128 | constructor(props, context) { 129 | super(props, context); 130 | this.state = { top: 0 }; 131 | this.handleScrollFrame = this.handleScrollFrame.bind(this); 132 | this.renderView = this.renderView.bind(this); 133 | } 134 | 135 | handleScrollFrame(values) { 136 | const { top } = values; 137 | this.setState({ top }); 138 | } 139 | 140 | renderView({ style, ...props }) { 141 | const { top } = this.state; 142 | const color = top * 255; 143 | const customStyle = { 144 | backgroundColor: `rgb(${color}, ${color}, ${color})`, 145 | }; 146 | return
; 147 | } 148 | 149 | render() { 150 | return ( 151 | 156 | ); 157 | } 158 | } 159 | ``` 160 | 161 | ### Check out [**DEMO**](/demo) for some inspiration 162 | -------------------------------------------------------------------------------- /test/Scrollbars/autoHeight.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode, findDOMNode } from 'react-dom'; 3 | import { Component } from 'react'; 4 | 5 | export default function createTests(scrollbarWidth, envScrollbarWidth) { 6 | describe('autoHeight', () => { 7 | let node; 8 | beforeEach(() => { 9 | node = document.createElement('div'); 10 | document.body.appendChild(node); 11 | }); 12 | afterEach(() => { 13 | unmountComponentAtNode(node); 14 | document.body.removeChild(node); 15 | }); 16 | 17 | describe('when rendered', () => { 18 | it('should have min-height and max-height', (done) => { 19 | render( 20 | 21 |
22 | , 23 | node, 24 | function callback() { 25 | const scrollbars = findDOMNode(this); 26 | expect(scrollbars.style.position).toEqual('relative'); 27 | expect(scrollbars.style.minHeight).toEqual('0px'); 28 | expect(scrollbars.style.maxHeight).toEqual('100px'); 29 | expect(this.view.style.position).toEqual('relative'); 30 | expect(this.view.style.minHeight).toEqual(`${scrollbarWidth}px`); 31 | expect(this.view.style.maxHeight).toEqual(`${100 + scrollbarWidth}px`); 32 | done(); 33 | }, 34 | ); 35 | }); 36 | }); 37 | 38 | describe('when native scrollbars have a width', () => { 39 | if (!scrollbarWidth) return; 40 | it('hides native scrollbars', (done) => { 41 | render( 42 | 43 |
44 | , 45 | node, 46 | function callback() { 47 | const width = `-${scrollbarWidth}px`; 48 | expect(this.view.style.marginRight).toEqual(width); 49 | expect(this.view.style.marginBottom).toEqual(width); 50 | done(); 51 | }, 52 | ); 53 | }); 54 | }); 55 | 56 | describe('when native scrollbars have no width', () => { 57 | if (scrollbarWidth) return; 58 | it('hides bars', (done) => { 59 | render( 60 | 61 |
62 | , 63 | node, 64 | function callback() { 65 | setTimeout(() => { 66 | expect(this.trackVertical.style.display).toEqual('none'); 67 | expect(this.trackHorizontal.style.display).toEqual('none'); 68 | done(); 69 | }, 100); 70 | }, 71 | ); 72 | }); 73 | }); 74 | 75 | describe('when content is smaller than maxHeight', () => { 76 | it("should have the content's height", (done) => { 77 | render( 78 | 79 |
80 | , 81 | node, 82 | function callback() { 83 | setTimeout(() => { 84 | const scrollbars = findDOMNode(this); 85 | expect(scrollbars.clientHeight).toEqual(50 + (envScrollbarWidth - scrollbarWidth)); 86 | expect(this.view.clientHeight).toEqual(50); 87 | expect(this.view.scrollHeight).toEqual(50); 88 | expect(this.thumbVertical.clientHeight).toEqual(0); 89 | done(); 90 | }, 100); 91 | }, 92 | ); 93 | }); 94 | }); 95 | 96 | describe('when content is larger than maxHeight', () => { 97 | it('should show scrollbars', (done) => { 98 | render( 99 | 100 |
101 | , 102 | node, 103 | function callback() { 104 | setTimeout(() => { 105 | const scrollbars = findDOMNode(this); 106 | expect(scrollbars.clientHeight).toEqual(100); 107 | expect(this.view.clientHeight).toEqual(100 - (envScrollbarWidth - scrollbarWidth)); 108 | expect(this.view.scrollHeight).toEqual(200); 109 | if (scrollbarWidth) { 110 | // 100 / 200 * 96 = 48 111 | expect(this.thumbVertical.clientHeight).toEqual(48); 112 | } 113 | done(); 114 | }, 100); 115 | }, 116 | ); 117 | }); 118 | }); 119 | 120 | describe('when minHeight is greater than 0', () => { 121 | it('should have height greater than 0', (done) => { 122 | render( 123 | 124 |
125 | , 126 | node, 127 | function callback() { 128 | setTimeout(() => { 129 | const scrollbars = findDOMNode(this); 130 | expect(scrollbars.clientHeight).toEqual(100); 131 | expect(this.view.clientHeight).toEqual(100 - (envScrollbarWidth - scrollbarWidth)); 132 | expect(this.thumbVertical.clientHeight).toEqual(0); 133 | done(); 134 | }, 100); 135 | }, 136 | ); 137 | }); 138 | }); 139 | 140 | describe('when using perecentages', () => { 141 | it('should use calc', (done) => { 142 | class Root extends Component { 143 | render() { 144 | return ( 145 |
146 | { 148 | this.scrollbars = ref; 149 | }} 150 | autoHeight 151 | autoHeightMin="50%" 152 | autoHeightMax="100%" 153 | > 154 |
155 | 156 |
157 | ); 158 | } 159 | } 160 | render(, node, function callback() { 161 | setTimeout(() => { 162 | const $scrollbars = findDOMNode(this.scrollbars); 163 | const view = this.scrollbars.view; 164 | expect($scrollbars.clientWidth).toEqual(500); 165 | expect($scrollbars.clientHeight).toEqual(250); 166 | expect($scrollbars.style.position).toEqual('relative'); 167 | expect($scrollbars.style.minHeight).toEqual('50%'); 168 | expect($scrollbars.style.maxHeight).toEqual('100%'); 169 | expect(view.style.position).toEqual('relative'); 170 | expect(view.style.minHeight).toEqual(`calc(50% + ${scrollbarWidth}px)`); 171 | expect(view.style.maxHeight).toEqual(`calc(100% + ${scrollbarWidth}px)`); 172 | done(); 173 | }, 100); 174 | }); 175 | }); 176 | }); 177 | 178 | describe('when using other units', () => { 179 | it('should use calc', (done) => { 180 | render( 181 | 182 |
183 | , 184 | node, 185 | function callback() { 186 | const scrollbars = findDOMNode(this); 187 | expect(scrollbars.style.position).toEqual('relative'); 188 | expect(scrollbars.style.minHeight).toEqual('10em'); 189 | expect(scrollbars.style.maxHeight).toEqual('100em'); 190 | expect(this.view.style.position).toEqual('relative'); 191 | expect(this.view.style.minHeight).toEqual(`calc(10em + ${scrollbarWidth}px)`); 192 | expect(this.view.style.maxHeight).toEqual(`calc(100em + ${scrollbarWidth}px)`); 193 | done(); 194 | }, 195 | ); 196 | }); 197 | }); 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /test/Scrollbars/scrolling.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | 4 | export default function createTests(scrollbarWidth, envScrollbarWidth) { 5 | let node; 6 | beforeEach(() => { 7 | node = document.createElement('div'); 8 | document.body.appendChild(node); 9 | }); 10 | afterEach(() => { 11 | unmountComponentAtNode(node); 12 | document.body.removeChild(node); 13 | }); 14 | 15 | describe('when scrolling', () => { 16 | describe('when native scrollbars have a width', () => { 17 | if (!scrollbarWidth) return; 18 | it('should update thumbs position', (done) => { 19 | render( 20 | 21 |
22 | , 23 | node, 24 | function callback() { 25 | this.scrollTop(50); 26 | this.scrollLeft(50); 27 | setTimeout(() => { 28 | if (scrollbarWidth) { 29 | // 50 / (200 - 100) * (96 - 48) = 24 30 | expect(this.thumbVertical.style.transform).toEqual('translateY(24px)'); 31 | expect(this.thumbHorizontal.style.transform).toEqual('translateX(24px)'); 32 | } else { 33 | expect(this.thumbVertical.style.transform).toEqual(''); 34 | expect(this.thumbHorizontal.style.transform).toEqual(''); 35 | } 36 | done(); 37 | }, 100); 38 | }, 39 | ); 40 | }); 41 | }); 42 | 43 | it('should not trigger a rerender', () => { 44 | render( 45 | 46 |
47 | , 48 | node, 49 | function callback() { 50 | const spy = spyOn(this, 'render').andCallThrough(); 51 | this.scrollTop(50); 52 | expect(spy.calls.length).toEqual(0); 53 | spy.restore(); 54 | }, 55 | ); 56 | }); 57 | 58 | describe('when scrolling x-axis', () => { 59 | it('should call `onScroll`', (done) => { 60 | const spy = createSpy(); 61 | render( 62 | 63 |
64 | , 65 | node, 66 | function callback() { 67 | this.scrollLeft(50); 68 | setTimeout(() => { 69 | expect(spy.calls.length).toEqual(1); 70 | const args = spy.calls[0].arguments; 71 | const event = args[0]; 72 | expect(event).toBeA(Event); 73 | done(); 74 | }, 100); 75 | }, 76 | ); 77 | }); 78 | it('should call `onScrollFrame`', (done) => { 79 | const spy = createSpy(); 80 | render( 81 | 82 |
83 | , 84 | node, 85 | function callback() { 86 | this.scrollLeft(50); 87 | setTimeout(() => { 88 | expect(spy.calls.length).toEqual(1); 89 | const args = spy.calls[0].arguments; 90 | const values = args[0]; 91 | expect(values).toBeA(Object); 92 | 93 | if (scrollbarWidth) { 94 | expect(values).toEqual({ 95 | left: 0.5, 96 | top: 0, 97 | scrollLeft: 50, 98 | scrollTop: 0, 99 | scrollWidth: 200, 100 | scrollHeight: 200, 101 | clientWidth: 100, 102 | clientHeight: 100, 103 | }); 104 | } else { 105 | expect(values).toEqual({ 106 | left: values.scrollLeft / (values.scrollWidth - values.clientWidth), 107 | top: 0, 108 | scrollLeft: 50, 109 | scrollTop: 0, 110 | scrollWidth: 200, 111 | scrollHeight: 200, 112 | clientWidth: 100 - envScrollbarWidth, 113 | clientHeight: 100 - envScrollbarWidth, 114 | }); 115 | } 116 | done(); 117 | }, 100); 118 | }, 119 | ); 120 | }); 121 | it('should call `onScrollStart` once', (done) => { 122 | const spy = createSpy(); 123 | render( 124 | 125 |
126 | , 127 | node, 128 | function callback() { 129 | let left = 0; 130 | const interval = setInterval(() => { 131 | this.scrollLeft(++left); 132 | if (left >= 50) { 133 | clearInterval(interval); 134 | expect(spy.calls.length).toEqual(1); 135 | done(); 136 | } 137 | }, 10); 138 | }, 139 | ); 140 | }); 141 | it('should call `onScrollStop` once when scrolling stops', (done) => { 142 | const spy = createSpy(); 143 | render( 144 | 145 |
146 | , 147 | node, 148 | function callback() { 149 | let left = 0; 150 | const interval = setInterval(() => { 151 | this.scrollLeft(++left); 152 | if (left >= 50) { 153 | clearInterval(interval); 154 | setTimeout(() => { 155 | expect(spy.calls.length).toEqual(1); 156 | done(); 157 | }, 300); 158 | } 159 | }, 10); 160 | }, 161 | ); 162 | }); 163 | }); 164 | 165 | describe('when scrolling y-axis', () => { 166 | it('should call `onScroll`', (done) => { 167 | const spy = createSpy(); 168 | render( 169 | 170 |
171 | , 172 | node, 173 | function callback() { 174 | this.scrollTop(50); 175 | setTimeout(() => { 176 | expect(spy.calls.length).toEqual(1); 177 | const args = spy.calls[0].arguments; 178 | const event = args[0]; 179 | expect(event).toBeA(Event); 180 | done(); 181 | }, 100); 182 | }, 183 | ); 184 | }); 185 | it('should call `onScrollFrame`', (done) => { 186 | const spy = createSpy(); 187 | render( 188 | 189 |
190 | , 191 | node, 192 | function callback() { 193 | this.scrollTop(50); 194 | setTimeout(() => { 195 | expect(spy.calls.length).toEqual(1); 196 | const args = spy.calls[0].arguments; 197 | const values = args[0]; 198 | expect(values).toBeA(Object); 199 | 200 | if (scrollbarWidth) { 201 | expect(values).toEqual({ 202 | left: 0, 203 | top: 0.5, 204 | scrollLeft: 0, 205 | scrollTop: 50, 206 | scrollWidth: 200, 207 | scrollHeight: 200, 208 | clientWidth: 100, 209 | clientHeight: 100, 210 | }); 211 | } else { 212 | expect(values).toEqual({ 213 | left: 0, 214 | top: values.scrollTop / (values.scrollHeight - values.clientHeight), 215 | scrollLeft: 0, 216 | scrollTop: 50, 217 | scrollWidth: 200, 218 | scrollHeight: 200, 219 | clientWidth: 100 - envScrollbarWidth, 220 | clientHeight: 100 - envScrollbarWidth, 221 | }); 222 | } 223 | done(); 224 | }, 100); 225 | }, 226 | ); 227 | }); 228 | it('should call `onScrollStart` once', (done) => { 229 | const spy = createSpy(); 230 | render( 231 | 232 |
233 | , 234 | node, 235 | function callback() { 236 | let top = 0; 237 | const interval = setInterval(() => { 238 | this.scrollTop(++top); 239 | if (top >= 50) { 240 | clearInterval(interval); 241 | expect(spy.calls.length).toEqual(1); 242 | done(); 243 | } 244 | }, 10); 245 | }, 246 | ); 247 | }); 248 | it('should call `onScrollStop` once when scrolling stops', (done) => { 249 | const spy = createSpy(); 250 | render( 251 | 252 |
253 | , 254 | node, 255 | function callback() { 256 | let top = 0; 257 | const interval = setInterval(() => { 258 | this.scrollTop(++top); 259 | if (top >= 50) { 260 | clearInterval(interval); 261 | setTimeout(() => { 262 | expect(spy.calls.length).toEqual(1); 263 | done(); 264 | }, 300); 265 | } 266 | }, 10); 267 | }, 268 | ); 269 | }); 270 | }); 271 | }); 272 | } 273 | -------------------------------------------------------------------------------- /test/Scrollbars/rendering.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode, findDOMNode } from 'react-dom'; 3 | 4 | export default function createTests(scrollbarWidth) { 5 | describe('rendering', () => { 6 | let node; 7 | beforeEach(() => { 8 | node = document.createElement('div'); 9 | document.body.appendChild(node); 10 | }); 11 | afterEach(() => { 12 | unmountComponentAtNode(node); 13 | document.body.removeChild(node); 14 | }); 15 | 16 | describe('when Scrollbars are rendered', () => { 17 | it('takes className', (done) => { 18 | render( 19 | 20 |
21 | , 22 | node, 23 | function callback() { 24 | expect(findDOMNode(this).className).toEqual('foo'); 25 | done(); 26 | }, 27 | ); 28 | }); 29 | 30 | it('takes id', (done) => { 31 | render( 32 | 33 |
34 | , 35 | node, 36 | function callback() { 37 | expect(findDOMNode(this).id).toEqual('foo'); 38 | done(); 39 | }, 40 | ); 41 | }); 42 | 43 | it('takes styles', (done) => { 44 | render( 45 | 46 |
47 | , 48 | node, 49 | function callback() { 50 | expect(findDOMNode(this).style.width).toEqual('100px'); 51 | expect(findDOMNode(this).style.height).toEqual('100px'); 52 | expect(findDOMNode(this).style.overflow).toEqual('hidden'); 53 | done(); 54 | }, 55 | ); 56 | }); 57 | 58 | it('renders view', (done) => { 59 | render( 60 | 61 |
62 | , 63 | node, 64 | function callback() { 65 | expect(this.view).toBeA(Node); 66 | done(); 67 | }, 68 | ); 69 | }); 70 | 71 | describe('when using custom tagName', () => { 72 | it('should use the defined tagName', (done) => { 73 | render( 74 | 75 |
76 | , 77 | node, 78 | function callback() { 79 | const el = findDOMNode(this); 80 | expect(el.tagName.toLowerCase()).toEqual('nav'); 81 | done(); 82 | }, 83 | ); 84 | }); 85 | }); 86 | 87 | describe('when custom `renderView` is passed', () => { 88 | it('should render custom element', (done) => { 89 | render( 90 | ( 93 |
94 | )} 95 | > 96 |
97 | , 98 | node, 99 | function callback() { 100 | expect(this.view.tagName).toEqual('SECTION'); 101 | expect(this.view.style.color).toEqual('red'); 102 | expect(this.view.style.position).toEqual('absolute'); 103 | done(); 104 | }, 105 | ); 106 | }); 107 | }); 108 | 109 | describe('when native scrollbars have a width', () => { 110 | if (!scrollbarWidth) return; 111 | 112 | it('hides native scrollbars', (done) => { 113 | render( 114 | 115 |
116 | , 117 | node, 118 | function callback() { 119 | setTimeout(() => { 120 | const width = `-${scrollbarWidth}px`; 121 | expect(this.view.style.marginRight).toEqual(width); 122 | expect(this.view.style.marginBottom).toEqual(width); 123 | done(); 124 | }, 100); 125 | }, 126 | ); 127 | }); 128 | 129 | it('renders bars', (done) => { 130 | render( 131 | 132 |
133 | , 134 | node, 135 | function callback() { 136 | expect(this.trackHorizontal).toBeA(Node); 137 | expect(this.trackVertical).toBeA(Node); 138 | done(); 139 | }, 140 | ); 141 | }); 142 | 143 | it('renders thumbs', (done) => { 144 | render( 145 | 146 |
147 | , 148 | node, 149 | function callback() { 150 | expect(this.thumbHorizontal).toBeA(Node); 151 | expect(this.thumbVertical).toBeA(Node); 152 | done(); 153 | }, 154 | ); 155 | }); 156 | 157 | it('renders thumbs with correct size', (done) => { 158 | render( 159 | 160 |
161 | , 162 | node, 163 | function callback() { 164 | setTimeout(() => { 165 | // 100 / 200 * 96 = 48 166 | expect(this.thumbVertical.style.height).toEqual('48px'); 167 | expect(this.thumbHorizontal.style.width).toEqual('48px'); 168 | done(); 169 | }, 100); 170 | }, 171 | ); 172 | }); 173 | 174 | it('the thumbs size should not be less than the given `thumbMinSize`', (done) => { 175 | render( 176 | 177 |
178 | , 179 | node, 180 | function callback() { 181 | setTimeout(() => { 182 | // 100 / 200 * 96 = 48 183 | expect(this.thumbVertical.style.height).toEqual('30px'); 184 | expect(this.thumbHorizontal.style.width).toEqual('30px'); 185 | done(); 186 | }, 100); 187 | }, 188 | ); 189 | }); 190 | 191 | describe('when thumbs have a fixed size', () => { 192 | it('thumbs should have the given fixed size', (done) => { 193 | render( 194 | 195 |
196 | , 197 | node, 198 | function callback() { 199 | setTimeout(() => { 200 | // 100 / 200 * 96 = 48 201 | expect(this.thumbVertical.style.height).toEqual('50px'); 202 | expect(this.thumbHorizontal.style.width).toEqual('50px'); 203 | done(); 204 | }, 100); 205 | }, 206 | ); 207 | }); 208 | }); 209 | 210 | describe('when custom `renderTrackHorizontal` is passed', () => { 211 | it('should render custom element', (done) => { 212 | render( 213 | ( 216 |
217 | )} 218 | > 219 |
220 | , 221 | node, 222 | function callback() { 223 | expect(this.trackHorizontal.tagName).toEqual('SECTION'); 224 | expect(this.trackHorizontal.style.position).toEqual('absolute'); 225 | expect(this.trackHorizontal.style.color).toEqual('red'); 226 | done(); 227 | }, 228 | ); 229 | }); 230 | }); 231 | 232 | describe('when custom `renderTrackVertical` is passed', () => { 233 | it('should render custom element', (done) => { 234 | render( 235 | ( 238 |
239 | )} 240 | > 241 |
242 | , 243 | node, 244 | function callback() { 245 | expect(this.trackVertical.tagName).toEqual('SECTION'); 246 | expect(this.trackVertical.style.position).toEqual('absolute'); 247 | expect(this.trackVertical.style.color).toEqual('red'); 248 | done(); 249 | }, 250 | ); 251 | }); 252 | }); 253 | 254 | describe('when custom `renderThumbHorizontal` is passed', () => { 255 | it('should render custom element', (done) => { 256 | render( 257 | ( 260 |
261 | )} 262 | > 263 |
264 | , 265 | node, 266 | function callback() { 267 | expect(this.thumbHorizontal.tagName).toEqual('SECTION'); 268 | expect(this.thumbHorizontal.style.position).toEqual('relative'); 269 | expect(this.thumbHorizontal.style.color).toEqual('red'); 270 | done(); 271 | }, 272 | ); 273 | }); 274 | }); 275 | 276 | describe('when custom `renderThumbVertical` is passed', () => { 277 | it('should render custom element', (done) => { 278 | render( 279 | ( 282 |
283 | )} 284 | > 285 |
286 | , 287 | node, 288 | function callback() { 289 | expect(this.thumbVertical.tagName).toEqual('SECTION'); 290 | expect(this.thumbVertical.style.position).toEqual('relative'); 291 | expect(this.thumbVertical.style.color).toEqual('red'); 292 | done(); 293 | }, 294 | ); 295 | }); 296 | }); 297 | 298 | it('positions view absolute', (done) => { 299 | render( 300 | 301 |
302 | , 303 | node, 304 | function callback() { 305 | expect(this.view.style.position).toEqual('absolute'); 306 | expect(this.view.style.top).toEqual('0px'); 307 | expect(this.view.style.left).toEqual('0px'); 308 | done(); 309 | }, 310 | ); 311 | }); 312 | 313 | it('should not override the scrollbars width/height values', (done) => { 314 | render( 315 | ( 318 |
319 | )} 320 | renderTrackVertical={({ style, ...props }) => ( 321 |
322 | )} 323 | > 324 |
325 | , 326 | node, 327 | function callback() { 328 | setTimeout(() => { 329 | expect(this.trackHorizontal.style.height).toEqual('10px'); 330 | expect(this.trackVertical.style.width).toEqual('10px'); 331 | done(); 332 | }, 100); 333 | }, 334 | ); 335 | }); 336 | 337 | describe('when view does not overflow container', () => { 338 | it('should hide scrollbars', (done) => { 339 | render( 340 | ( 343 |
344 | )} 345 | renderTrackVertical={({ style, ...props }) => ( 346 |
347 | )} 348 | > 349 |
350 | , 351 | node, 352 | function callback() { 353 | setTimeout(() => { 354 | expect(this.thumbHorizontal.style.width).toEqual('0px'); 355 | expect(this.thumbVertical.style.height).toEqual('0px'); 356 | done(); 357 | }, 100); 358 | }, 359 | ); 360 | }); 361 | }); 362 | }); 363 | 364 | describe('when native scrollbars have no width', () => { 365 | if (scrollbarWidth) return; 366 | 367 | it('hides bars', (done) => { 368 | render( 369 | 370 |
371 | , 372 | node, 373 | function callback() { 374 | setTimeout(() => { 375 | expect(this.trackVertical.style.display).toEqual('none'); 376 | expect(this.trackHorizontal.style.display).toEqual('none'); 377 | done(); 378 | }, 100); 379 | }, 380 | ); 381 | }); 382 | }); 383 | }); 384 | 385 | describe('when rerendering Scrollbars', () => { 386 | function renderScrollbars(callback) { 387 | render( 388 | 389 |
390 | , 391 | node, 392 | callback, 393 | ); 394 | } 395 | it('should update scrollbars', (done) => { 396 | renderScrollbars(function callback() { 397 | const spy = spyOn(this, 'update').andCallThrough(); 398 | renderScrollbars(function rerenderCallback() { 399 | expect(spy.calls.length).toEqual(1); 400 | spy.restore(); 401 | done(); 402 | }); 403 | }); 404 | }); 405 | }); 406 | }); 407 | } 408 | -------------------------------------------------------------------------------- /test/Scrollbars/autoHide.js: -------------------------------------------------------------------------------- 1 | import { Scrollbars } from 'react-custom-scrollbars'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | import simulant from 'simulant'; 4 | 5 | export default function createTests(scrollbarWidth) { 6 | // Not for mobile environment 7 | if (!scrollbarWidth) return; 8 | 9 | let node; 10 | beforeEach(() => { 11 | node = document.createElement('div'); 12 | document.body.appendChild(node); 13 | }); 14 | afterEach(() => { 15 | unmountComponentAtNode(node); 16 | document.body.removeChild(node); 17 | }); 18 | 19 | describe('autoHide', () => { 20 | describe('when Scrollbars are rendered', () => { 21 | it('should hide tracks', (done) => { 22 | render( 23 | 24 |
25 | , 26 | node, 27 | function callback() { 28 | expect(this.trackHorizontal.style.opacity).toEqual('0'); 29 | expect(this.trackVertical.style.opacity).toEqual('0'); 30 | done(); 31 | }, 32 | ); 33 | }); 34 | }); 35 | describe('enter/leave track', () => { 36 | describe('when entering horizontal track', () => { 37 | it('should show tracks', (done) => { 38 | render( 39 | 40 |
41 | , 42 | node, 43 | function callback() { 44 | const { trackHorizontal: track } = this; 45 | simulant.fire(track, 'mouseenter'); 46 | expect(track.style.opacity).toEqual('1'); 47 | done(); 48 | }, 49 | ); 50 | }); 51 | it('should not hide tracks', (done) => { 52 | render( 53 | 54 |
55 | , 56 | node, 57 | function callback() { 58 | const { trackHorizontal: track } = this; 59 | simulant.fire(track, 'mouseenter'); 60 | setTimeout(() => this.hideTracks(), 10); 61 | setTimeout(() => { 62 | expect(track.style.opacity).toEqual('1'); 63 | }, 100); 64 | done(); 65 | }, 66 | ); 67 | }); 68 | }); 69 | describe('when leaving horizontal track', () => { 70 | it('should hide tracks', (done) => { 71 | render( 72 | 78 |
79 | , 80 | node, 81 | function callback() { 82 | const { trackHorizontal: track } = this; 83 | simulant.fire(track, 'mouseenter'); 84 | simulant.fire(track, 'mouseleave'); 85 | setTimeout(() => { 86 | expect(track.style.opacity).toEqual('0'); 87 | done(); 88 | }, 100); 89 | }, 90 | ); 91 | }); 92 | }); 93 | describe('when entering vertical track', () => { 94 | it('should show tracks', (done) => { 95 | render( 96 | 97 |
98 | , 99 | node, 100 | function callback() { 101 | const { trackVertical: track } = this; 102 | simulant.fire(track, 'mouseenter'); 103 | expect(track.style.opacity).toEqual('1'); 104 | done(); 105 | }, 106 | ); 107 | }); 108 | it('should not hide tracks', (done) => { 109 | render( 110 | 111 |
112 | , 113 | node, 114 | function callback() { 115 | const { trackVertical: track } = this; 116 | simulant.fire(track, 'mouseenter'); 117 | setTimeout(() => this.hideTracks(), 10); 118 | setTimeout(() => { 119 | expect(track.style.opacity).toEqual('1'); 120 | }, 100); 121 | done(); 122 | }, 123 | ); 124 | }); 125 | }); 126 | describe('when leaving vertical track', () => { 127 | it('should hide tracks', (done) => { 128 | render( 129 | 135 |
136 | , 137 | node, 138 | function callback() { 139 | const { trackVertical: track } = this; 140 | simulant.fire(track, 'mouseenter'); 141 | simulant.fire(track, 'mouseleave'); 142 | setTimeout(() => { 143 | expect(track.style.opacity).toEqual('0'); 144 | done(); 145 | }, 100); 146 | }, 147 | ); 148 | }); 149 | }); 150 | }); 151 | describe('when scrolling', () => { 152 | it('should show tracks', (done) => { 153 | render( 154 | 155 |
156 | , 157 | node, 158 | function callback() { 159 | this.scrollTop(50); 160 | setTimeout(() => { 161 | const { trackHorizontal, trackVertical } = this; 162 | expect(trackHorizontal.style.opacity).toEqual('1'); 163 | expect(trackVertical.style.opacity).toEqual('1'); 164 | done(); 165 | }, 100); 166 | }, 167 | ); 168 | }); 169 | it('should hide tracks after scrolling', (done) => { 170 | render( 171 | 177 |
178 | , 179 | node, 180 | function callback() { 181 | this.scrollTop(50); 182 | setTimeout(() => { 183 | const { trackHorizontal, trackVertical } = this; 184 | expect(trackHorizontal.style.opacity).toEqual('0'); 185 | expect(trackVertical.style.opacity).toEqual('0'); 186 | done(); 187 | }, 300); 188 | }, 189 | ); 190 | }); 191 | it('should not hide tracks', (done) => { 192 | render( 193 | 194 |
195 | , 196 | node, 197 | function callback() { 198 | this.scrollTop(50); 199 | setTimeout(() => this.hideTracks()); 200 | setTimeout(() => { 201 | const { trackHorizontal, trackVertical } = this; 202 | expect(trackHorizontal.style.opacity).toEqual('1'); 203 | expect(trackVertical.style.opacity).toEqual('1'); 204 | done(); 205 | }, 50); 206 | }, 207 | ); 208 | }); 209 | }); 210 | describe('when dragging x-axis', () => { 211 | it('should show tracks', (done) => { 212 | render( 213 | 219 |
220 | , 221 | node, 222 | function callback() { 223 | const { thumbHorizontal: thumb, trackHorizontal: track } = this; 224 | const { left } = thumb.getBoundingClientRect(); 225 | simulant.fire(thumb, 'mousedown', { 226 | target: thumb, 227 | clientX: left + 1, 228 | }); 229 | simulant.fire(document, 'mousemove', { 230 | clientX: left + 100, 231 | }); 232 | setTimeout(() => { 233 | expect(track.style.opacity).toEqual('1'); 234 | done(); 235 | }, 100); 236 | }, 237 | ); 238 | }); 239 | 240 | it('should hide tracks on end', (done) => { 241 | render( 242 | 248 |
249 | , 250 | node, 251 | function callback() { 252 | const { thumbHorizontal: thumb, trackHorizontal: track } = this; 253 | const { left } = thumb.getBoundingClientRect(); 254 | simulant.fire(thumb, 'mousedown', { 255 | target: thumb, 256 | clientX: left + 1, 257 | }); 258 | simulant.fire(document, 'mouseup'); 259 | setTimeout(() => { 260 | expect(track.style.opacity).toEqual('0'); 261 | done(); 262 | }, 100); 263 | }, 264 | ); 265 | }); 266 | 267 | describe('and leaving track', () => { 268 | it('should not hide tracks', (done) => { 269 | render( 270 | 276 |
277 | , 278 | node, 279 | function callback() { 280 | setTimeout(() => { 281 | const { thumbHorizontal: thumb, trackHorizontal: track } = this; 282 | const { left } = thumb.getBoundingClientRect(); 283 | simulant.fire(thumb, 'mousedown', { 284 | target: thumb, 285 | clientX: left + 1, 286 | }); 287 | simulant.fire(document, 'mousemove', { 288 | clientX: left + 100, 289 | }); 290 | simulant.fire(track, 'mouseleave'); 291 | setTimeout(() => { 292 | expect(track.style.opacity).toEqual('1'); 293 | done(); 294 | }, 200); 295 | }, 100); 296 | }, 297 | ); 298 | }); 299 | }); 300 | }); 301 | describe('when dragging y-axis', () => { 302 | it('should show tracks', (done) => { 303 | render( 304 | 310 |
311 | , 312 | node, 313 | function callback() { 314 | const { thumbVertical: thumb, trackVertical: track } = this; 315 | const { top } = thumb.getBoundingClientRect(); 316 | simulant.fire(thumb, 'mousedown', { 317 | target: thumb, 318 | clientY: top + 1, 319 | }); 320 | simulant.fire(document, 'mousemove', { 321 | clientY: top + 100, 322 | }); 323 | setTimeout(() => { 324 | expect(track.style.opacity).toEqual('1'); 325 | done(); 326 | }, 100); 327 | }, 328 | ); 329 | }); 330 | it('should hide tracks on end', (done) => { 331 | render( 332 | 338 |
339 | , 340 | node, 341 | function callback() { 342 | const { thumbVertical: thumb, trackVertical: track } = this; 343 | const { top } = thumb.getBoundingClientRect(); 344 | simulant.fire(thumb, 'mousedown', { 345 | target: thumb, 346 | clientY: top + 1, 347 | }); 348 | simulant.fire(document, 'mouseup'); 349 | setTimeout(() => { 350 | expect(track.style.opacity).toEqual('0'); 351 | done(); 352 | }, 100); 353 | }, 354 | ); 355 | }); 356 | describe('and leaving track', () => { 357 | it('should not hide tracks', (done) => { 358 | render( 359 | 365 |
366 | , 367 | node, 368 | function callback() { 369 | setTimeout(() => { 370 | const { thumbVertical: thumb, trackVertical: track } = this; 371 | const { top } = thumb.getBoundingClientRect(); 372 | simulant.fire(thumb, 'mousedown', { 373 | target: thumb, 374 | clientY: top + 1, 375 | }); 376 | simulant.fire(document, 'mousemove', { 377 | clientY: top + 100, 378 | }); 379 | simulant.fire(track, 'mouseleave'); 380 | setTimeout(() => { 381 | expect(track.style.opacity).toEqual('1'); 382 | done(); 383 | }, 200); 384 | }, 100); 385 | }, 386 | ); 387 | }); 388 | }); 389 | }); 390 | }); 391 | 392 | describe('when autoHide is disabed', () => { 393 | describe('enter/leave track', () => { 394 | describe('when entering horizontal track', () => { 395 | it('should not call `showTracks`', (done) => { 396 | render( 397 | 398 |
399 | , 400 | node, 401 | function callback() { 402 | const spy = spyOn(this, 'showTracks'); 403 | const { trackHorizontal: track } = this; 404 | simulant.fire(track, 'mouseenter'); 405 | expect(spy.calls.length).toEqual(0); 406 | done(); 407 | }, 408 | ); 409 | }); 410 | }); 411 | describe('when leaving horizontal track', () => { 412 | it('should not call `hideTracks`', (done) => { 413 | render( 414 | 415 |
416 | , 417 | node, 418 | function callback() { 419 | const spy = spyOn(this, 'hideTracks'); 420 | const { trackHorizontal: track } = this; 421 | simulant.fire(track, 'mouseenter'); 422 | simulant.fire(track, 'mouseleave'); 423 | setTimeout(() => { 424 | expect(spy.calls.length).toEqual(0); 425 | done(); 426 | }, 100); 427 | }, 428 | ); 429 | }); 430 | }); 431 | describe('when entering vertical track', () => { 432 | it('should not call `showTracks`', (done) => { 433 | render( 434 | 435 |
436 | , 437 | node, 438 | function callback() { 439 | const spy = spyOn(this, 'showTracks'); 440 | const { trackVertical: track } = this; 441 | simulant.fire(track, 'mouseenter'); 442 | expect(spy.calls.length).toEqual(0); 443 | done(); 444 | }, 445 | ); 446 | }); 447 | }); 448 | describe('when leaving vertical track', () => { 449 | it('should not call `hideTracks`', (done) => { 450 | render( 451 | 452 |
453 | , 454 | node, 455 | function callback() { 456 | const spy = spyOn(this, 'hideTracks'); 457 | const { trackVertical: track } = this; 458 | simulant.fire(track, 'mouseenter'); 459 | simulant.fire(track, 'mouseleave'); 460 | setTimeout(() => { 461 | expect(spy.calls.length).toEqual(0); 462 | done(); 463 | }, 100); 464 | }, 465 | ); 466 | }); 467 | }); 468 | }); 469 | }); 470 | } 471 | -------------------------------------------------------------------------------- /src/Scrollbars/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cloneElement, Component, createElement, CSSProperties } from 'react'; 3 | import raf, { cancel as caf } from 'raf'; 4 | import css from 'dom-css'; 5 | // 6 | import { ScrollValues, ScrollbarsProps, StyleKeys } from './types'; 7 | import { 8 | getFinalClasses, 9 | getScrollbarWidth, 10 | returnFalse, 11 | getInnerWidth, 12 | getInnerHeight, 13 | } from '../utils'; 14 | import { createStyles } from './styles'; 15 | 16 | interface State { 17 | didMountUniversal: boolean; 18 | scrollbarWidth: number; 19 | } 20 | 21 | export class Scrollbars extends Component { 22 | container: Element | null = null; 23 | detectScrollingInterval: any; // Node timeout bug 24 | dragging: boolean = false; 25 | hideTracksTimeout: any; // Node timeout bug 26 | lastViewScrollLeft?: number; 27 | lastViewScrollTop?: number; 28 | prevPageX?: number; 29 | prevPageY?: number; 30 | requestFrame?: number; 31 | scrolling: boolean = false; 32 | styles: Record; 33 | thumbHorizontal?: HTMLDivElement; 34 | thumbVertical?: HTMLDivElement; 35 | trackHorizontal?: HTMLDivElement; 36 | trackMouseOver: boolean = false; 37 | trackVertical?: HTMLDivElement; 38 | view?: HTMLElement; 39 | viewScrollLeft?: number; 40 | viewScrollTop?: number; 41 | 42 | static defaultProps = { 43 | autoHeight: false, 44 | autoHeightMax: 200, 45 | autoHeightMin: 0, 46 | autoHide: false, 47 | autoHideDuration: 200, 48 | autoHideTimeout: 1000, 49 | disableDefaultStyles: false, 50 | hideTracksWhenNotNeeded: false, 51 | renderThumbHorizontal: (props) =>
, 52 | renderThumbVertical: (props) =>
, 53 | renderTrackHorizontal: (props) =>
, 54 | renderTrackVertical: (props) =>
, 55 | renderView: (props) =>
, 56 | tagName: 'div', 57 | thumbMinSize: 30, 58 | universal: false, 59 | }; 60 | 61 | constructor(props: ScrollbarsProps) { 62 | super(props); 63 | 64 | this.styles = createStyles(this.props.disableDefaultStyles); 65 | 66 | this.getScrollLeft = this.getScrollLeft.bind(this); 67 | this.getScrollTop = this.getScrollTop.bind(this); 68 | this.getScrollWidth = this.getScrollWidth.bind(this); 69 | this.getScrollHeight = this.getScrollHeight.bind(this); 70 | this.getClientWidth = this.getClientWidth.bind(this); 71 | this.getClientHeight = this.getClientHeight.bind(this); 72 | this.getValues = this.getValues.bind(this); 73 | this.getThumbHorizontalWidth = this.getThumbHorizontalWidth.bind(this); 74 | this.getThumbVerticalHeight = this.getThumbVerticalHeight.bind(this); 75 | this.getScrollLeftForOffset = this.getScrollLeftForOffset.bind(this); 76 | this.getScrollTopForOffset = this.getScrollTopForOffset.bind(this); 77 | 78 | this.scrollLeft = this.scrollLeft.bind(this); 79 | this.scrollTop = this.scrollTop.bind(this); 80 | this.scrollToLeft = this.scrollToLeft.bind(this); 81 | this.scrollToTop = this.scrollToTop.bind(this); 82 | this.scrollToRight = this.scrollToRight.bind(this); 83 | this.scrollToBottom = this.scrollToBottom.bind(this); 84 | 85 | this.handleTrackMouseEnter = this.handleTrackMouseEnter.bind(this); 86 | this.handleTrackMouseLeave = this.handleTrackMouseLeave.bind(this); 87 | this.handleHorizontalTrackMouseDown = this.handleHorizontalTrackMouseDown.bind(this); 88 | this.handleVerticalTrackMouseDown = this.handleVerticalTrackMouseDown.bind(this); 89 | this.handleHorizontalThumbMouseDown = this.handleHorizontalThumbMouseDown.bind(this); 90 | this.handleVerticalThumbMouseDown = this.handleVerticalThumbMouseDown.bind(this); 91 | this.handleWindowResize = this.handleWindowResize.bind(this); 92 | this.handleScroll = this.handleScroll.bind(this); 93 | this.handleDrag = this.handleDrag.bind(this); 94 | this.handleDragEnd = this.handleDragEnd.bind(this); 95 | 96 | this.state = { 97 | didMountUniversal: false, 98 | scrollbarWidth: getScrollbarWidth(), 99 | }; 100 | } 101 | 102 | componentDidMount() { 103 | this.addListeners(); 104 | this.update(); 105 | this.componentDidMountUniversal(); 106 | } 107 | 108 | componentDidMountUniversal() { 109 | // eslint-disable-line react/sort-comp 110 | const { universal } = this.props; 111 | if (!universal) return; 112 | this.setState({ didMountUniversal: true }); 113 | } 114 | 115 | componentDidUpdate() { 116 | this.update(); 117 | } 118 | 119 | componentWillUnmount() { 120 | this.removeListeners(); 121 | this.requestFrame && caf(this.requestFrame); 122 | clearTimeout(this.hideTracksTimeout); 123 | clearInterval(this.detectScrollingInterval); 124 | } 125 | 126 | getScrollLeft() { 127 | if (!this.view) return 0; 128 | return this.view.scrollLeft; 129 | } 130 | 131 | getScrollTop() { 132 | if (!this.view) return 0; 133 | return this.view.scrollTop; 134 | } 135 | 136 | getScrollWidth() { 137 | if (!this.view) return 0; 138 | return this.view.scrollWidth; 139 | } 140 | 141 | getScrollHeight() { 142 | if (!this.view) return 0; 143 | return this.view.scrollHeight; 144 | } 145 | 146 | getClientWidth() { 147 | if (!this.view) return 0; 148 | return this.view.clientWidth; 149 | } 150 | 151 | getClientHeight() { 152 | if (!this.view) return 0; 153 | return this.view.clientHeight; 154 | } 155 | 156 | getValues() { 157 | const { 158 | scrollLeft = 0, 159 | scrollTop = 0, 160 | scrollWidth = 0, 161 | scrollHeight = 0, 162 | clientWidth = 0, 163 | clientHeight = 0, 164 | } = this.view || {}; 165 | 166 | return { 167 | left: scrollLeft / (scrollWidth - clientWidth) || 0, 168 | top: scrollTop / (scrollHeight - clientHeight) || 0, 169 | scrollLeft, 170 | scrollTop, 171 | scrollWidth, 172 | scrollHeight, 173 | clientWidth, 174 | clientHeight, 175 | }; 176 | } 177 | 178 | getThumbHorizontalWidth() { 179 | if (!this.view || !this.trackHorizontal) return 0; 180 | const { thumbSize, thumbMinSize } = this.props; 181 | const { scrollWidth, clientWidth } = this.view; 182 | const trackWidth = getInnerWidth(this.trackHorizontal); 183 | const width = Math.ceil((clientWidth / scrollWidth) * trackWidth); 184 | if (trackWidth === width) return 0; 185 | if (thumbSize) return thumbSize; 186 | return Math.max(width, thumbMinSize); 187 | } 188 | 189 | getThumbVerticalHeight() { 190 | if (!this.view || !this.trackVertical) return 0; 191 | const { thumbSize, thumbMinSize } = this.props; 192 | const { scrollHeight, clientHeight } = this.view; 193 | const trackHeight = getInnerHeight(this.trackVertical); 194 | const height = Math.ceil((clientHeight / scrollHeight) * trackHeight); 195 | if (trackHeight === height) return 0; 196 | if (thumbSize) return thumbSize; 197 | return Math.max(height, thumbMinSize); 198 | } 199 | 200 | getScrollLeftForOffset(offset) { 201 | if (!this.view || !this.trackHorizontal) return 0; 202 | const { scrollWidth, clientWidth } = this.view; 203 | const trackWidth = getInnerWidth(this.trackHorizontal); 204 | const thumbWidth = this.getThumbHorizontalWidth(); 205 | return (offset / (trackWidth - thumbWidth)) * (scrollWidth - clientWidth); 206 | } 207 | 208 | getScrollTopForOffset(offset) { 209 | if (!this.view || !this.trackVertical) return 0; 210 | const { scrollHeight, clientHeight } = this.view; 211 | const trackHeight = getInnerHeight(this.trackVertical); 212 | const thumbHeight = this.getThumbVerticalHeight(); 213 | return (offset / (trackHeight - thumbHeight)) * (scrollHeight - clientHeight); 214 | } 215 | 216 | scrollLeft(left = 0) { 217 | if (!this.view) return; 218 | this.view.scrollLeft = left; 219 | } 220 | 221 | scrollTop(top = 0) { 222 | if (!this.view) return; 223 | this.view.scrollTop = top; 224 | } 225 | 226 | scrollToLeft() { 227 | if (!this.view) return; 228 | this.view.scrollLeft = 0; 229 | } 230 | 231 | scrollToTop() { 232 | if (!this.view) return; 233 | this.view.scrollTop = 0; 234 | } 235 | 236 | scrollToRight() { 237 | if (!this.view) return; 238 | this.view.scrollLeft = this.view.scrollWidth; 239 | } 240 | 241 | scrollToBottom() { 242 | if (!this.view) return; 243 | this.view.scrollTop = this.view.scrollHeight; 244 | } 245 | 246 | scrollToY(y: number) { 247 | if (!this.view) return; 248 | this.view.scrollTop = y; 249 | } 250 | 251 | addListeners() { 252 | /* istanbul ignore if */ 253 | if ( 254 | typeof document === 'undefined' || 255 | !this.view || 256 | !this.trackHorizontal || 257 | !this.trackVertical || 258 | !this.thumbVertical || 259 | !this.thumbHorizontal 260 | ) 261 | return; 262 | 263 | const { view, trackHorizontal, trackVertical, thumbHorizontal, thumbVertical } = this; 264 | view.addEventListener('scroll', this.handleScroll); 265 | 266 | if (!this.state.scrollbarWidth) return; 267 | 268 | trackHorizontal.addEventListener('mouseenter', this.handleTrackMouseEnter); 269 | trackHorizontal.addEventListener('mouseleave', this.handleTrackMouseLeave); 270 | trackHorizontal.addEventListener('mousedown', this.handleHorizontalTrackMouseDown); 271 | trackVertical.addEventListener('mouseenter', this.handleTrackMouseEnter); 272 | trackVertical.addEventListener('mouseleave', this.handleTrackMouseLeave); 273 | trackVertical.addEventListener('mousedown', this.handleVerticalTrackMouseDown); 274 | thumbHorizontal.addEventListener('mousedown', this.handleHorizontalThumbMouseDown); 275 | thumbVertical.addEventListener('mousedown', this.handleVerticalThumbMouseDown); 276 | window.addEventListener('resize', this.handleWindowResize); 277 | } 278 | 279 | removeListeners() { 280 | /* istanbul ignore if */ 281 | if ( 282 | typeof document === 'undefined' || 283 | !this.view || 284 | !this.trackHorizontal || 285 | !this.trackVertical || 286 | !this.thumbVertical || 287 | !this.thumbHorizontal 288 | ) 289 | return; 290 | const { view, trackHorizontal, trackVertical, thumbHorizontal, thumbVertical } = this; 291 | view.removeEventListener('scroll', this.handleScroll); 292 | 293 | if (!this.state.scrollbarWidth) return; 294 | 295 | trackHorizontal.removeEventListener('mouseenter', this.handleTrackMouseEnter); 296 | trackHorizontal.removeEventListener('mouseleave', this.handleTrackMouseLeave); 297 | trackHorizontal.removeEventListener('mousedown', this.handleHorizontalTrackMouseDown); 298 | trackVertical.removeEventListener('mouseenter', this.handleTrackMouseEnter); 299 | trackVertical.removeEventListener('mouseleave', this.handleTrackMouseLeave); 300 | trackVertical.removeEventListener('mousedown', this.handleVerticalTrackMouseDown); 301 | thumbHorizontal.removeEventListener('mousedown', this.handleHorizontalThumbMouseDown); 302 | thumbVertical.removeEventListener('mousedown', this.handleVerticalThumbMouseDown); 303 | window.removeEventListener('resize', this.handleWindowResize); 304 | // Possibly setup by `handleDragStart` 305 | this.teardownDragging(); 306 | } 307 | 308 | handleScroll(event) { 309 | const { onScroll, onScrollFrame } = this.props; 310 | if (onScroll) onScroll(event); 311 | this.update((values: ScrollValues) => { 312 | const { scrollLeft, scrollTop } = values; 313 | this.viewScrollLeft = scrollLeft; 314 | this.viewScrollTop = scrollTop; 315 | if (onScrollFrame) onScrollFrame(values); 316 | }); 317 | this.detectScrolling(); 318 | } 319 | 320 | handleScrollStart() { 321 | const { onScrollStart } = this.props; 322 | if (onScrollStart) onScrollStart(); 323 | this.handleScrollStartAutoHide(); 324 | } 325 | 326 | handleScrollStartAutoHide() { 327 | const { autoHide } = this.props; 328 | if (!autoHide) return; 329 | this.showTracks(); 330 | } 331 | 332 | handleScrollStop() { 333 | const { onScrollStop } = this.props; 334 | if (onScrollStop) onScrollStop(); 335 | this.handleScrollStopAutoHide(); 336 | } 337 | 338 | handleScrollStopAutoHide() { 339 | const { autoHide } = this.props; 340 | if (!autoHide) return; 341 | this.hideTracks(); 342 | } 343 | 344 | handleWindowResize() { 345 | this.update(); 346 | } 347 | 348 | handleHorizontalTrackMouseDown(event) { 349 | if (!this.view) return; 350 | event.preventDefault(); 351 | const { target, clientX } = event; 352 | const { left: targetLeft } = target.getBoundingClientRect(); 353 | const thumbWidth = this.getThumbHorizontalWidth(); 354 | const offset = Math.abs(targetLeft - clientX) - thumbWidth / 2; 355 | this.view.scrollLeft = this.getScrollLeftForOffset(offset); 356 | } 357 | 358 | handleVerticalTrackMouseDown(event) { 359 | if (!this.view) return; 360 | event.preventDefault(); 361 | const { target, clientY } = event; 362 | const { top: targetTop } = target.getBoundingClientRect(); 363 | const thumbHeight = this.getThumbVerticalHeight(); 364 | const offset = Math.abs(targetTop - clientY) - thumbHeight / 2; 365 | this.view.scrollTop = this.getScrollTopForOffset(offset); 366 | } 367 | 368 | handleHorizontalThumbMouseDown(event) { 369 | event.preventDefault(); 370 | this.handleDragStart(event); 371 | const { target, clientX } = event; 372 | const { offsetWidth } = target; 373 | const { left } = target.getBoundingClientRect(); 374 | this.prevPageX = offsetWidth - (clientX - left); 375 | } 376 | 377 | handleVerticalThumbMouseDown(event) { 378 | event.preventDefault(); 379 | this.handleDragStart(event); 380 | const { target, clientY } = event; 381 | const { offsetHeight } = target; 382 | const { top } = target.getBoundingClientRect(); 383 | this.prevPageY = offsetHeight - (clientY - top); 384 | } 385 | 386 | setupDragging() { 387 | css(document.body, this.styles.disableSelectStyle); 388 | document.addEventListener('mousemove', this.handleDrag); 389 | document.addEventListener('mouseup', this.handleDragEnd); 390 | document.onselectstart = returnFalse; 391 | } 392 | 393 | teardownDragging() { 394 | css(document.body, this.styles.disableSelectStyleReset); 395 | document.removeEventListener('mousemove', this.handleDrag); 396 | document.removeEventListener('mouseup', this.handleDragEnd); 397 | document.onselectstart = null; 398 | } 399 | 400 | handleDragStart(event) { 401 | this.dragging = true; 402 | event.stopImmediatePropagation(); 403 | this.setupDragging(); 404 | } 405 | 406 | handleDrag(event) { 407 | if (this.prevPageX && this.trackHorizontal && this.view) { 408 | const { clientX } = event; 409 | const { left: trackLeft } = this.trackHorizontal.getBoundingClientRect(); 410 | const thumbWidth = this.getThumbHorizontalWidth(); 411 | const clickPosition = thumbWidth - this.prevPageX; 412 | const offset = -trackLeft + clientX - clickPosition; 413 | this.view.scrollLeft = this.getScrollLeftForOffset(offset); 414 | } 415 | if (this.prevPageY && this.trackVertical && this.view) { 416 | const { clientY } = event; 417 | const { top: trackTop } = this.trackVertical.getBoundingClientRect(); 418 | const thumbHeight = this.getThumbVerticalHeight(); 419 | const clickPosition = thumbHeight - this.prevPageY; 420 | const offset = -trackTop + clientY - clickPosition; 421 | this.view.scrollTop = this.getScrollTopForOffset(offset); 422 | } 423 | return false; 424 | } 425 | 426 | handleDragEnd() { 427 | this.dragging = false; 428 | this.prevPageX = this.prevPageY = 0; 429 | this.teardownDragging(); 430 | this.handleDragEndAutoHide(); 431 | } 432 | 433 | handleDragEndAutoHide() { 434 | const { autoHide } = this.props; 435 | if (!autoHide) return; 436 | this.hideTracks(); 437 | } 438 | 439 | handleTrackMouseEnter() { 440 | this.trackMouseOver = true; 441 | this.handleTrackMouseEnterAutoHide(); 442 | } 443 | 444 | handleTrackMouseEnterAutoHide() { 445 | const { autoHide } = this.props; 446 | if (!autoHide) return; 447 | this.showTracks(); 448 | } 449 | 450 | handleTrackMouseLeave() { 451 | this.trackMouseOver = false; 452 | this.handleTrackMouseLeaveAutoHide(); 453 | } 454 | 455 | handleTrackMouseLeaveAutoHide() { 456 | const { autoHide } = this.props; 457 | if (!autoHide) return; 458 | this.hideTracks(); 459 | } 460 | 461 | showTracks() { 462 | clearTimeout(this.hideTracksTimeout); 463 | css(this.trackHorizontal, { opacity: 1 }); 464 | css(this.trackVertical, { opacity: 1 }); 465 | } 466 | 467 | hideTracks() { 468 | if (this.dragging) return; 469 | if (this.scrolling) return; 470 | if (this.trackMouseOver) return; 471 | const { autoHideTimeout } = this.props; 472 | clearTimeout(this.hideTracksTimeout); 473 | this.hideTracksTimeout = setTimeout(() => { 474 | css(this.trackHorizontal, { opacity: 0 }); 475 | css(this.trackVertical, { opacity: 0 }); 476 | }, autoHideTimeout); 477 | } 478 | 479 | detectScrolling() { 480 | if (this.scrolling) return; 481 | this.scrolling = true; 482 | this.handleScrollStart(); 483 | this.detectScrollingInterval = setInterval(() => { 484 | if ( 485 | this.lastViewScrollLeft === this.viewScrollLeft && 486 | this.lastViewScrollTop === this.viewScrollTop 487 | ) { 488 | clearInterval(this.detectScrollingInterval); 489 | this.scrolling = false; 490 | this.handleScrollStop(); 491 | } 492 | this.lastViewScrollLeft = this.viewScrollLeft; 493 | this.lastViewScrollTop = this.viewScrollTop; 494 | }, 100); 495 | } 496 | 497 | raf(callback) { 498 | if (this.requestFrame) raf.cancel(this.requestFrame); 499 | this.requestFrame = raf(() => { 500 | this.requestFrame = undefined; 501 | callback(); 502 | }); 503 | } 504 | 505 | update(callback?: (values: ScrollValues) => void) { 506 | this.raf(() => this._update(callback)); 507 | } 508 | 509 | _update(callback) { 510 | const { onUpdate, hideTracksWhenNotNeeded } = this.props; 511 | const values = this.getValues(); 512 | 513 | const freshScrollbarWidth = getScrollbarWidth(); 514 | 515 | if (this.state.scrollbarWidth !== freshScrollbarWidth) { 516 | this.setState({ scrollbarWidth: freshScrollbarWidth }); 517 | } 518 | 519 | if (this.state.scrollbarWidth) { 520 | const { scrollLeft, clientWidth, scrollWidth } = values; 521 | const trackHorizontalWidth = getInnerWidth(this.trackHorizontal); 522 | 523 | const thumbHorizontalWidth = this.getThumbHorizontalWidth(); 524 | const thumbHorizontalX = 525 | (scrollLeft / (scrollWidth - clientWidth)) * (trackHorizontalWidth - thumbHorizontalWidth); 526 | const thumbHorizontalStyle = { 527 | width: thumbHorizontalWidth, 528 | transform: `translateX(${thumbHorizontalX}px)`, 529 | }; 530 | const { scrollTop, clientHeight, scrollHeight } = values; 531 | const trackVerticalHeight = getInnerHeight(this.trackVertical); 532 | const thumbVerticalHeight = this.getThumbVerticalHeight(); 533 | const thumbVerticalY = 534 | (scrollTop / (scrollHeight - clientHeight)) * (trackVerticalHeight - thumbVerticalHeight); 535 | const thumbVerticalStyle = { 536 | height: thumbVerticalHeight, 537 | transform: `translateY(${thumbVerticalY}px)`, 538 | }; 539 | if (hideTracksWhenNotNeeded) { 540 | const trackHorizontalStyle = { 541 | visibility: scrollWidth > clientWidth ? 'visible' : 'hidden', 542 | }; 543 | const trackVerticalStyle = { 544 | visibility: scrollHeight > clientHeight ? 'visible' : 'hidden', 545 | }; 546 | css(this.trackHorizontal, trackHorizontalStyle); 547 | css(this.trackVertical, trackVerticalStyle); 548 | } 549 | css(this.thumbHorizontal, thumbHorizontalStyle); 550 | css(this.thumbVertical, thumbVerticalStyle); 551 | } 552 | if (onUpdate) onUpdate(values); 553 | if (typeof callback !== 'function') return; 554 | callback(values); 555 | } 556 | 557 | render() { 558 | const { scrollbarWidth, didMountUniversal } = this.state; 559 | 560 | /* eslint-disable no-unused-vars */ 561 | const { 562 | autoHeight, 563 | autoHeightMax, 564 | autoHeightMin, 565 | autoHide, 566 | autoHideDuration, 567 | autoHideTimeout, 568 | children, 569 | classes, 570 | hideTracksWhenNotNeeded, 571 | onScroll, 572 | onScrollFrame, 573 | onScrollStart, 574 | onScrollStop, 575 | onUpdate, 576 | renderThumbHorizontal, 577 | renderThumbVertical, 578 | renderTrackHorizontal, 579 | renderTrackVertical, 580 | renderView, 581 | style, 582 | tagName, 583 | thumbMinSize, 584 | thumbSize, 585 | universal, 586 | disableDefaultStyles, 587 | ...props 588 | } = this.props; 589 | /* eslint-enable no-unused-vars */ 590 | 591 | const { 592 | containerStyleAutoHeight, 593 | containerStyleDefault, 594 | thumbStyleDefault, 595 | trackHorizontalStyleDefault, 596 | trackVerticalStyleDefault, 597 | viewStyleAutoHeight, 598 | viewStyleDefault, 599 | viewStyleUniversalInitial, 600 | } = this.styles; 601 | 602 | const containerStyle = { 603 | ...containerStyleDefault, 604 | ...(autoHeight && { 605 | ...containerStyleAutoHeight, 606 | minHeight: autoHeightMin, 607 | maxHeight: autoHeightMax, 608 | }), 609 | ...style, 610 | }; 611 | 612 | const viewStyle = { 613 | ...viewStyleDefault, 614 | // Hide scrollbars by setting a negative margin 615 | marginRight: scrollbarWidth ? -scrollbarWidth : 0, 616 | marginBottom: scrollbarWidth ? -scrollbarWidth : 0, 617 | ...(autoHeight && { 618 | ...viewStyleAutoHeight, 619 | // Add scrollbarWidth to autoHeight in order to compensate negative margins 620 | minHeight: 621 | typeof autoHeightMin === 'string' 622 | ? `calc(${autoHeightMin} + ${scrollbarWidth}px)` 623 | : autoHeightMin + scrollbarWidth, 624 | maxHeight: 625 | typeof autoHeightMax === 'string' 626 | ? `calc(${autoHeightMax} + ${scrollbarWidth}px)` 627 | : autoHeightMax + scrollbarWidth, 628 | }), 629 | // Override min/max height for initial universal rendering 630 | ...(autoHeight && 631 | universal && 632 | !didMountUniversal && { 633 | minHeight: autoHeightMin, 634 | maxHeight: autoHeightMax, 635 | }), 636 | // Override 637 | ...(universal && !didMountUniversal && viewStyleUniversalInitial), 638 | }; 639 | 640 | const trackAutoHeightStyle = { 641 | transition: `opacity ${autoHideDuration}ms`, 642 | opacity: 0, 643 | }; 644 | 645 | const trackHorizontalStyle = { 646 | ...trackHorizontalStyleDefault, 647 | ...(autoHide && trackAutoHeightStyle), 648 | ...((!scrollbarWidth || (universal && !didMountUniversal)) && { 649 | display: 'none', 650 | }), 651 | }; 652 | 653 | const trackVerticalStyle = { 654 | ...trackVerticalStyleDefault, 655 | ...(autoHide && trackAutoHeightStyle), 656 | ...((!scrollbarWidth || (universal && !didMountUniversal)) && { 657 | display: 'none', 658 | }), 659 | }; 660 | 661 | const mergedClasses = getFinalClasses(this.props); 662 | 663 | return createElement( 664 | tagName, 665 | { 666 | ...props, 667 | className: mergedClasses.root, 668 | style: containerStyle, 669 | ref: (ref) => { 670 | this.container = ref; 671 | }, 672 | }, 673 | [ 674 | cloneElement( 675 | renderView({ 676 | style: viewStyle, 677 | className: mergedClasses.view, 678 | }), 679 | { 680 | key: 'view', 681 | ref: (ref) => { 682 | this.view = ref; 683 | }, 684 | }, 685 | children, 686 | ), 687 | cloneElement( 688 | renderTrackHorizontal({ 689 | style: trackHorizontalStyle, 690 | className: mergedClasses.trackHorizontal, 691 | }), 692 | { 693 | key: 'trackHorizontal', 694 | ref: (ref) => { 695 | this.trackHorizontal = ref; 696 | }, 697 | }, 698 | cloneElement( 699 | renderThumbHorizontal({ 700 | style: thumbStyleDefault, 701 | className: mergedClasses.thumbHorizontal, 702 | }), 703 | { 704 | ref: (ref) => { 705 | this.thumbHorizontal = ref; 706 | }, 707 | }, 708 | ), 709 | ), 710 | cloneElement( 711 | renderTrackVertical({ 712 | style: trackVerticalStyle, 713 | className: mergedClasses.trackVertical, 714 | }), 715 | { 716 | key: 'trackVertical', 717 | ref: (ref) => { 718 | this.trackVertical = ref; 719 | }, 720 | }, 721 | cloneElement( 722 | renderThumbVertical({ 723 | style: thumbStyleDefault, 724 | className: mergedClasses.thumbVertical, 725 | }), 726 | { 727 | ref: (ref) => { 728 | this.thumbVertical = ref; 729 | }, 730 | }, 731 | ), 732 | ), 733 | ], 734 | ); 735 | } 736 | } 737 | --------------------------------------------------------------------------------