├── .eslintrc.js
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE.md
├── dependabot.yml
└── workflows
│ └── ci.yaml
├── .gitignore
├── .npmignore
├── .prettierrc.js
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── karma.conf.ts
├── package.json
├── rollup.config.js
├── src
├── Emittr.ts
├── Loop.ts
├── Scrollbar.tsx
├── ScrollbarThumb.tsx
├── ScrollbarTrack.tsx
├── index.ts
├── style.ts
├── types.ts
└── util.tsx
├── testbench
├── app
│ ├── index.html
│ └── index.tsx
├── benchmarks.html
├── package.json
├── webpack.config.js
└── yarn.lock
├── tests
├── Emittr.spec.ts
├── Loop.spec.ts
├── Scrollbar.spec.tsx
├── ScrollbarThumb.spec.tsx
├── ScrollbarTrack.spec.tsx
└── util.spec.tsx
├── tsconfig.eslint.json
├── tsconfig.json
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | ignorePatterns: [
5 | 'node_modules',
6 | 'coverage',
7 | 'dist',
8 | '.github/workflows',
9 | '.husky',
10 | 'CHANGELOG.md',
11 | ],
12 |
13 | parserOptions: {
14 | project: './tsconfig.eslint.json',
15 | tsconfigRootDir: __dirname,
16 | extraFileExtensions: ['.mdx', '.md'],
17 | },
18 |
19 | overrides: [
20 | {
21 | files: ['*.js', '*.ts', '*.jsx', '*.tsx'],
22 | extends: ['@react-hookz/eslint-config/react'],
23 | rules: {
24 | // ToDo: remove below after refactoring
25 | 'no-underscore-dangle': 'off',
26 | 'import/no-default-export': 'off',
27 | 'no-bitwise': 'off',
28 | '@typescript-eslint/naming-convention': 'off',
29 | 'react/destructuring-assignment': 'off',
30 | 'unicorn/consistent-destructuring': 'off',
31 | 'react/no-unused-class-component-methods': 'off',
32 | '@typescript-eslint/no-unsafe-call': 'off',
33 | '@typescript-eslint/no-explicit-any': 'off',
34 | '@typescript-eslint/no-unsafe-member-access': 'off',
35 | '@typescript-eslint/no-unsafe-assignment': 'off',
36 | '@typescript-eslint/no-unsafe-argument': 'off',
37 | 'no-use-before-define': 'off',
38 | '@typescript-eslint/no-non-null-assertion': 'off',
39 | },
40 | },
41 | {
42 | files: ['tests/*.js', 'tests/*.ts', 'tests/*.jsx', 'tests/*.tsx'],
43 | extends: ['@react-hookz/eslint-config/react'],
44 | rules: {
45 | // ToDo: remove below after refactoring
46 | 'no-underscore-dangle': 'off',
47 | 'import/no-default-export': 'off',
48 | '@typescript-eslint/naming-convention': 'off',
49 | '@typescript-eslint/no-unsafe-call': 'off',
50 | '@typescript-eslint/no-explicit-any': 'off',
51 | '@typescript-eslint/no-unsafe-member-access': 'off',
52 | '@typescript-eslint/no-unsafe-assignment': 'off',
53 | '@typescript-eslint/no-unsafe-argument': 'off',
54 | 'no-prototype-builtins': 'off',
55 | '@typescript-eslint/no-unused-vars': 'off',
56 | 'no-bitwise': 'off',
57 | 'func-names': 'off',
58 | '@typescript-eslint/restrict-plus-operands': 'off',
59 | 'react/jsx-no-bind': 'off',
60 | 'react/no-unused-state': 'off',
61 | 'react/destructuring-assignment': 'off',
62 | 'unicorn/consistent-destructuring': 'off',
63 | 'react/no-unused-class-component-methods': 'off',
64 | 'no-use-before-define': 'off',
65 | '@typescript-eslint/no-non-null-assertion': 'off',
66 | },
67 | },
68 | {
69 | files: ['*.md', '*.mdx'],
70 | extends: ['@react-hookz/eslint-config/mdx'],
71 | },
72 | ],
73 | };
74 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | patreon: xobotyi
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **What is the current behavior?**
11 |
12 | **Steps to reproduce it and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn't have extra dependencies other than `react-scrollbars-custom`. Paste the link to your [JSFiddle](https://jsfiddle.net) or [CodeSandbox](https://codesandbox.io) example below:**
13 |
14 | **What is the expected behavior?**
15 |
16 | **A little about versions:**
17 | - _OS_:
18 | - _Browser (vendor and version)_:
19 | - _React_:
20 | - _`react-scrollbars-custom`_:
21 | - _Did this worked in the previous package version?_
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # Description
2 |
3 |
4 |
5 | ## Use case
6 |
7 |
8 |
9 | ## Type of change
10 |
11 |
12 |
13 | - [x] _Bug fix_ (non-breaking change which fixes an issue)
14 | - [x] _New feature_ (non-breaking change which adds functionality)
15 | - [x] **_Breaking change_** (fix or feature that would cause existing functionality to not work as before)
16 |
17 | # Checklist
18 |
19 |
20 |
21 | - [ ] Perform a code self-review
22 | - [ ] Comment the code, particularly in hard-to-understand areas
23 | - [ ] Update/add the documentation
24 | - [ ] Write tests
25 | - [ ] Ensure the test suite passes (`yarn test`)
26 | - [ ] Provide 100% tests coverage
27 |
28 |
29 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "00:00"
8 | timezone: "Etc/UTC"
9 |
10 | - package-ecosystem: npm
11 | directory: /
12 | schedule:
13 | interval: daily
14 | time: "00:00"
15 | timezone: "Etc/UTC"
16 | rebase-strategy: 'auto'
17 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: "CI"
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - master
10 | workflow_dispatch:
11 |
12 | env:
13 | HUSKY: 0
14 |
15 | jobs:
16 | lint:
17 | name: "Lint"
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: "Checkout"
21 | uses: actions/checkout@v3
22 | with:
23 | fetch-depth: 0
24 |
25 | - uses: actions/setup-node@v3
26 | with:
27 | node-version: 16
28 |
29 | - uses: bahmutov/npm-install@v1
30 | with:
31 | useRollingCache: true
32 | install-command: yarn --frozen-lockfile
33 |
34 | - name: "Lint"
35 | run: yarn lint -f @jamesacarr/github-actions
36 |
37 | test:
38 | name: "Test"
39 | runs-on: ubuntu-latest
40 | steps:
41 | - name: "Checkout"
42 | uses: actions/checkout@v3
43 | with:
44 | fetch-depth: 0
45 |
46 | - uses: actions/setup-node@v3
47 | with:
48 | node-version: 16
49 |
50 | - uses: bahmutov/npm-install@v1
51 | with:
52 | useRollingCache: true
53 | install-command: yarn --frozen-lockfile
54 |
55 | - name: "Test"
56 | run: yarn test
57 |
58 | - name: "Upload coverage to Codecov"
59 | uses: codecov/codecov-action@v3
60 | with:
61 | token: ${{ secrets.CODECOV_TOKEN }}
62 | files: coverage/lcov.info
63 | fail_ci_if_error: true
64 |
65 | build:
66 | name: "Build"
67 | runs-on: ubuntu-latest
68 | steps:
69 | - name: "Checkout"
70 | uses: actions/checkout@v3
71 | with:
72 | fetch-depth: 0
73 |
74 | - uses: actions/setup-node@v3
75 | with:
76 | node-version: 16
77 |
78 | - uses: bahmutov/npm-install@v1
79 | with:
80 | useRollingCache: true
81 | install-command: yarn --frozen-lockfile
82 |
83 | - name: "Build"
84 | run: yarn build
85 |
86 | semantic-release:
87 | name: "Release"
88 | runs-on: ubuntu-latest
89 | needs: [ "lint", "test", "build" ]
90 | permissions:
91 | pull-requests: write
92 | contents: write
93 | if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
94 | outputs:
95 | new-release-published: ${{ steps.release.outputs.new-release-published }}
96 | steps:
97 | - name: "Checkout"
98 | uses: actions/checkout@v3
99 | with:
100 | fetch-depth: 0
101 |
102 | - uses: actions/setup-node@v3
103 | with:
104 | node-version: 16
105 |
106 | - uses: bahmutov/npm-install@v1
107 | with:
108 | useRollingCache: true
109 | install-command: yarn --frozen-lockfile
110 |
111 | - name: "Build package"
112 | run: yarn build
113 |
114 | - name: "Release"
115 | id: "release"
116 | uses: codfish/semantic-release-action@v2.2.0
117 | env:
118 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
119 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
120 |
121 | dependabot-merge:
122 | name: "Dependabot automerge"
123 | runs-on: ubuntu-latest
124 | needs: [ "lint", "test", "build" ]
125 | permissions:
126 | pull-requests: write
127 | contents: write
128 | steps:
129 | - uses: fastify/github-action-merge-dependabot@v3.9.0
130 | with:
131 | github-token: ${{ secrets.GITHUB_TOKEN }}
132 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .rpt2_cache
3 | package-lock.json
4 | node_modules
5 | coverage
6 | dist
7 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .rpt2_cache
3 | package-lock.json
4 | node_modules
5 | coverage
6 | src
7 | tests
8 | testbench
9 | .gitignore
10 | .travis.yml
11 | CODE_OF_CONDUCT.md
12 | rollup.config.js
13 | tsconfig.json
14 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | const config = require("@react-hookz/eslint-config/.prettierrc.js");
2 |
3 | module.exports = config;
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [4.1.1](https://github.com/xobotyi/react-scrollbars-custom/compare/v4.1.0...v4.1.1) (2022-08-15)
2 |
3 |
4 | ### Bug Fixes
5 |
6 | * replace `global` with browser env detection ([4c39fa3](https://github.com/xobotyi/react-scrollbars-custom/commit/4c39fa3a4c9485d00d5ea46930c0f3a4e732f914))
7 |
8 | # [4.1.0](https://github.com/xobotyi/react-scrollbars-custom/compare/v4.0.25...v4.1.0) (2022-07-25)
9 |
10 |
11 | ### Bug Fixes
12 |
13 | * explicit children typing ([#259](https://github.com/xobotyi/react-scrollbars-custom/issues/259)) ([c52e21f](https://github.com/xobotyi/react-scrollbars-custom/commit/c52e21f6be4c0630a83c2de6d82d8ee1a5c19699))
14 |
15 |
16 | ### Features
17 |
18 | * get rid of prop-types and set peerDependency of react to >=16 ([c3e50ae](https://github.com/xobotyi/react-scrollbars-custom/commit/c3e50ae0a57372598be6b5dfef0434c874c1a47a))
19 |
20 | # CHANGELOG
21 |
22 | ## v4.0.17
23 |
24 | - Fixed focus loss on thumbnail click (#113);
25 | - Fixed React error on native mode (#107)
26 |
27 | ## v4.0.17
28 |
29 | - Fixed focus loss on thumbnail click (#113);
30 | - Fixed React error on native mode (#107)
31 |
32 | ## v4.0.16
33 |
34 | - Small code refactoring;
35 | - Possible fix of #89;
36 | - Fix of #57;
37 |
38 | ## v4.0.15
39 |
40 | - Fixed cjs and esm builds;
41 |
42 | ## v4.0.14
43 |
44 | - Improved typings of getInner\* functions (no more @ts-ignore);
45 | - The proper scrollbar width detection now it is float number so no more 1px scrollbar showing off (Complete fix: #57);
46 | - Improved testbench;
47 |
48 | ## v4.0.13
49 |
50 | - Fix: #98;
51 | - Improved props typings;
52 |
53 | ## v4.0.12
54 |
55 | - Reverted the dist ESM filenames from `.mjs` to `.esm.js` due to lack of functionality of node modules system.
56 |
57 | ## v4.0.11
58 |
59 | - A bit tweaked distribution strategy:
60 | - `main` field of `package.json` is pointing to transpiled ES3-compatible version with CJS modules resolution;
61 | - `module` field is pointing to transpiled ES3-compatible version with ES modules resolution;
62 | - `esnext` field is pointing to the ES6+ version with ES modules resolution;
63 |
64 | ## v4.0.10
65 |
66 | - Refusing `is-fun` due to too big performance impact - no sense to use it with hte prop-types =\
67 | - Refusing `is-number` for almost the same reasons;
68 |
69 | ## v4.0.9
70 |
71 | - ESM version now has ESNext lang level;
72 | - CJS version now has ES3 lang level;
73 | - Now using [is-fun](https://github.com/xobotyi/is-fun) to detect callable props;
74 |
75 | ### v4.0.0-alpha.23
76 |
77 | - Added `mobileNative` prop
78 |
79 | ### v4.0.0-alpha.21
80 |
81 | - Fix: [#71](https://github.com/xobotyi/react-scrollbars-custom/issues/71);
82 | - Fixed and improved sizes translation;
83 | - Added `disableTrack*MousewheelScrolling` props;
84 | - Prop `compensateScrollbarsWidth` inverted and renamed to `disableTracksWidthCompensation`;
85 | - Added `disableTrackXWidthCompensation` and `disableTrackYWidthCompensation` props;
86 |
87 | ### v4.0.0-alpha.20
88 |
89 | - Fix: [#68](https://github.com/xobotyi/react-scrollbars-custom/issues/68);
90 | - Sizes loosing optimisation;
91 |
92 | ### v4.0.0-alpha.19
93 |
94 | - Content element now has the minHeight & minWidth styles if content sizes translation is off (Fix: #65 );
95 | - Vertical scrollbar now has no hard stick to any side;
96 |
97 | ### v4.0.0-alpha.18
98 |
99 | - Fix: #63
100 | - Fix: #48
101 |
102 | ### v4.0.0-alpha.17
103 |
104 | - Sizes translation fixes and improvements;
105 | - Added `compensateScrollbarsWidth` prop to be able make an overflowing scrollbars if needed.
106 | Also useful when sizes translation enabled;
107 |
108 | ### v4.0.0-alpha.15
109 |
110 | - Due to some issues with content paddings added extra wrapper element;
111 | - Little API changes;
112 | - Classnames changes;
113 |
114 | > **NOTE**
115 | > Feel sorry fo breaking capability for the third time during the v4-alpha stage but i have to do it to name things properly.
116 | > Earlier some things been named not obvious and there was a little mess with classnames.
117 | > From now i can surely say that basic elements classnames are locked and API will be developed with maximum backward capability.
118 | > Sorry for any inconvenience. 🙏
119 |
120 | ### v4.0.0-alpha.14
121 |
122 | - Component was fully reworked with power of TypeScript;
123 | - Inner kitchen was optimised and now it is 1.5-2 times faster;
124 | - A lot of API and semantics and classnames changes - better to treat it as a whole new component;
125 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | - Trolling, insulting/derogatory comments, and personal or political attacks
28 | - Public or private harassment
29 | - Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | - Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Anton Zinovyev
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # react-scrollbars-custom
4 |
5 | [](https://www.npmjs.com/package/react-scrollbars-custom)
6 | [](https://www.npmjs.com/package/react-scrollbars-custom)
7 | [](https://www.npmjs.com/package/react-scrollbars-custom)
8 | [](https://github.com/xobotyi/react-scrollbars-custom/actions)
9 | [](https://app.codecov.io/gh/xobotyi/react-scrollbars-custom)
10 | [](https://www.npmjs.com/package/react-scrollbars-custom)
11 | [](https://bundlephobia.com/result?p=react-scrollbars-custom)
12 |
13 | × **[DEMO](https://xobotyi.github.io/react-scrollbars-custom/)**
14 | × **[LIVE EXAMPLE](https://codesandbox.io/s/rsc-live-example-i1zlx?module=%2Fsrc%2FRSCExample.jsx)**
15 | × **[CHANGELOG](https://github.com/xobotyi/react-scrollbars-custom/blob/master/CHANGELOG.md)** ×
16 |
17 |
18 |
19 | > **Maintainers Wanted!**
20 | > If you're interested in helping with project maintenance - please contact me throgh email or twitter (links in profile)
21 |
22 | ---
23 |
24 | - Native browser scroll behavior - it don't emulate scrolling, only showing custom scrollbars,
25 | scrolling itself still native
26 | - Cross-browser and cross-platform - does not matter where and how, scrollbars looks the same
27 | everywhere
28 | - Ultimate performance - 60 FPS (using RAF) and highly optimised code
29 | - No extra stylesheets required - minimum inline styles out of the box or you can style it yourself
30 | however you want
31 | - Fully customizable - want a hippo as a scrollbar thumb? Well.. I don't judge you, you're free to
32 | do it!
33 | - Scrollbars nesting
34 | - Total tests coverage
35 | - Momentum scrolling for iOS
36 | - RTL support ([read more](#rtl-support))
37 | - Content sizes translation ([read more](#content-sizes-translation))
38 | - Proper page zoom handling (native scrollbars does not show up)
39 |
40 | ## INSTALLATION
41 |
42 | ```bash
43 | npm install react-scrollbars-custom
44 | # or via yarn
45 | yarn add react-scrollbars-custom
46 | ```
47 |
48 | **INSTALLATION NOTE:**
49 | This lib is written in ES6+ and delivering with both, transpiled and untranspiled versions:
50 |
51 | - `main` field of `package.json` is pointing to transpiled ES3-compatible version with CJS modules
52 | resolution;
53 | - `module` field is pointing to transpiled ES3-compatible version with ES modules resolution;
54 | - `esnext` field is pointing to the ES6+ version with ES modules resolution;
55 |
56 | Depending on your targets you may have to use [Webpack](https://webpack.js.org/) and/or
57 | [Babel](http://babeljs.io/) to pull untranspiled version of package.
58 | See some tips on wiring thing
59 | up: [https://2ality.com/2017/06/pkg-esnext.html](https://2ality.com/2017/06/pkg-esnext.html)
60 |
61 | ## USAGE
62 |
63 | Underneath `react-scrollbars-custom` uses `requestAnimationFrame` loop, which check and update each
64 | known scrollbar, and as result - scrollbars updates synchronised with browser's render flow.
65 | The `` component works out of the box, with only need of `width` and `height` to be
66 | set, inline or via CSS;
67 |
68 | ```typescript jsx
69 | import { Scrollbar } from 'react-scrollbars-custom';
70 |
71 |
72 | Hello world!
73 | ;
74 | ```
75 |
76 | ### Internet Explorer
77 |
78 | `react-scrollbars-custom` is syntax-compatible with IE10, but you'll have to use polyfills - for
79 | example [@babel/polyfill](https://babeljs.io/docs/en/babel-polyfill).
80 |
81 | #### Styling
82 |
83 | Probably you'll wish to customize your scrollbars on your own way via CSS - then simply
84 | pass `noDefaultStyles` prop - it will prevent all inline styling from appear.
85 | But some of styles will remain due to their need for proper component work.
86 |
87 | #### Native mode
88 |
89 | One more pretty common need is to disable custom scrollbars and fallback to native ones, it can be
90 | done by passing `native` prop.
91 | It'll change the generated markup:
92 |
93 | ```html
94 | // scrollbar.scrollerElement
95 |
99 | ```
100 |
101 | As you see here - now the root element has the `scrollerElement` ref, but otherwise its treated as a
102 | before (as holder). `contentElement` behaves as it was before.
103 |
104 | #### Content sizes translation
105 |
106 | In some situations you may want to make the scrollbars block of variable sizes - just
107 | pass `translateContentSize*ToHolder` prop and component will automatically translate
108 | corresponding `contentElement`'s sizes to the `holderElement`.
109 | If you are using default styles - it'll be handy to pass `disableTracksWidthCompensation` props, to
110 | avoid infinite shrinking when it's not supposed to.
111 | _Note:_ This wont work for native mode.
112 |
113 | #### RTL support
114 |
115 | `react-scrollbars-custom` supports RTL direction out of the box, you don't have to pass extra
116 | parameters to make it work, it'll be detected automatically on first component's render. But you
117 | still able to override it through the prop.
118 | There are several things you have to know about:
119 |
120 | - Due to performance reasons direction autodetect happens is 3 cases:
121 | - On component mount;
122 | - On rtl property change;
123 | - On state `isRtl` set to non-boolean value;
124 | - `rtl` property has priority over the `style` or CSS properties;
125 | - If `rtl` prop has not been set (undefined) - direction will be detected automatically according to
126 | content element's CSS;
127 | - If `rtl` prop is `true` - `direction: rtl;` style will be applied to hte content element;
128 | - If `rtl` prop is `false` - no style will be applied to holder;
129 |
130 | ## Customization
131 |
132 | In some cases you may want to change the default className or tagName of elements or add extra
133 | markup or whatever. For these purposes `react-scrollbars-custom` made fully customizable.
134 | You can do absolutely what ever you want y simply passing renderer SFC to the needed props.
135 |
136 | > **IMPORTANT**: Renderer will receive elementRef function that expect the DOM element's reference
137 | > as first parameter.
138 | > Furthermore you have to pass the styles, cause they needed to proper component work.
139 |
140 | ```typescript jsx
141 | {
143 | const { elementRef, ...restProps } = props;
144 | return ;
145 | }}
146 | wrapperProps={{
147 | renderer: (props) => {
148 | const { elementRef, ...restProps } = props;
149 | return ;
150 | },
151 | }}
152 | scrollerProps={{
153 | renderer: (props) => {
154 | const { elementRef, ...restProps } = props;
155 | return ;
156 | },
157 | }}
158 | contentProps={{
159 | renderer: (props) => {
160 | const { elementRef, ...restProps } = props;
161 | return ;
162 | },
163 | }}
164 | trackXProps={{
165 | renderer: (props) => {
166 | const { elementRef, ...restProps } = props;
167 | return ;
168 | },
169 | }}
170 | trackYProps={{
171 | renderer: (props) => {
172 | const { elementRef, ...restProps } = props;
173 | return ;
174 | },
175 | }}
176 | thumbXProps={{
177 | renderer: (props) => {
178 | const { elementRef, ...restProps } = props;
179 | return ;
180 | },
181 | }}
182 | thumbYProps={{
183 | renderer: (props) => {
184 | const { elementRef, ...restProps } = props;
185 | return ;
186 | },
187 | }}
188 | />
189 | ```
190 |
191 | #### Generated HTML
192 |
193 | ```html
194 | // scrollbar.holderElement
195 |
215 | ```
216 |
217 | - If scrolling is possible or tracks are shown die to `permanentScrollbar*` prop - `trackYVisible`
218 | /`trackXVisible` classnames are applied to the holder element.
219 | - When thumb is dragged it'll have `dragging` classname.
220 | - If direction is RTL - `rtl` classname will be added to the holder element.
221 | - By default whole structure described above is rendered in spite of tracks visibility, but it can
222 | be changed by passing `removeTrackXWhenNotUsed`/`removeTrackYWhenNotUsed`
223 | /`removeTracksWhenNotUsed` props to the component. Respective tracks will not be rendered if it is
224 | unnecessary.
225 |
226 | ## API
227 |
228 | ### PROPS
229 |
230 | You can pass any HTMLDivElement props to the component - they'll be respectfully passed to the
231 | holder element/renderer.
232 |
233 | **createContext** _`:boolean`_ = undefined
234 | Whether to create context that will contain scrollbar instance reference.
235 |
236 | **rtl** _`:boolean`_ = undefined
237 | `true` - set content's direction RTL, `false` - LTR, `undefined` - autodetect according content's
238 | style.
239 |
240 | **native** _`:boolean`_ = undefined
241 | Do not use custom scrollbars, use native ones instead.
242 |
243 | **mobileNative** _`:boolean`_ = undefined
244 | As `native` but enables only on mobile devices (actually when the `scrollbarWidth` is 0).
245 |
246 | **momentum** _`:boolean`_ = true
247 | Whether to use momentum scrolling, suitable for iOS (will add `-webkit-overflow-scrolling: touch` to
248 | the content element).
249 |
250 | **noDefaultStyles** _`:boolean`_ = undefined
251 | Whether to use default visual styles.
252 | _Note:_ Styles needed to proper component work will be passed regardless of this option.
253 |
254 | **disableTracksMousewheelScrolling** _`:boolean`_ = undefined
255 | Disable content scrolling while preforming a wheel event over the track.
256 |
257 | **disableTrackXMousewheelScrolling** _`:boolean`_ = undefined
258 | Disable content scrolling while preforming a wheel event over the track.
259 |
260 | **disableTrackYMousewheelScrolling** _`:boolean`_ = undefined
261 | Disable content scrolling while preforming a wheel event over the track.
262 |
263 | **disableTracksWidthCompensation** _`:boolean`_ = undefined
264 | Disable both vertical and horizontal wrapper indents that added in order to not let tracks overlay
265 | content.
266 | _Note:_ Works only with default styles enabled.
267 |
268 | **disableTrackXWidthCompensation** _`:boolean`_ = undefined
269 | Disable horizontal wrapper indents that added in order to not let horizontal track overlay content.
270 | _Note:_ Works only with default styles enabled.
271 |
272 | **disableTrackYWidthCompensation** _`:boolean`_ = undefined
273 | Disable vertical wrapper indents that added in order to not let vertical track overlay content.
274 | _Note:_ Works only with default styles enabled.
275 |
276 | **minimalThumbSize** _`:number`_ = 30
277 | Minimal size of both, vertical and horizontal thumbs. This option has priority
278 | to `minimalThumbXSize`/`minimalThumbYSize` props.
279 |
280 | **maximalThumbSize** _`:number`_ = undefined
281 | Maximal size of both, vertical and horizontal thumbs. This option has priority
282 | to `maximalThumbXSize`/`maximalThumbYSize` props.
283 |
284 | **minimalThumbXSize** _`:number`_ = undefined
285 | Minimal size of horizontal thumb.
286 |
287 | **maximalThumbXSize** _`:number`_ = undefined
288 | Maximal size of horizontal thumb.
289 |
290 | **minimalThumbYSize** _`:number`_ = undefined
291 | Minimal size of vertical thumb.
292 |
293 | **maximalThumbYSize** _`:number`_ = undefined
294 | Maximal size of vertical thumb.
295 |
296 | **noScroll** _`:boolean`_ = undefined
297 | Whether to disable both vertical and horizontal scrolling.
298 |
299 | **noScrollX** _`:boolean`_ = undefined
300 | Whether to disable horizontal scrolling.
301 |
302 | **noScrollY** _`:boolean`_ = undefined
303 | Whether to disable vertical scrolling.
304 |
305 | **permanentTracks** _`:boolean`_ = undefined
306 | Whether to display both tracks regardless of scrolling ability.
307 |
308 | **permanentTrackX** _`:boolean`_ = undefined
309 | Whether to display horizontal track regardless of scrolling ability.
310 |
311 | **permanentTrackY** _`:boolean`_ = undefined
312 | Whether to display vertical track regardless of scrolling ability.
313 |
314 | **removeTracksWhenNotUsed** _`:boolean`_ = undefined
315 | Whether to remove both vertical and horizontal tracks if scrolling is not possible/blocked and
316 | tracks are not permanent.
317 |
318 | **removeTrackYWhenNotUsed** _`:boolean`_ = undefined
319 | Whether to remove vertical track if scrolling is not possible/blocked and tracks are not permanent.
320 |
321 | **removeTrackXWhenNotUsed** _`:boolean`_ = undefined
322 | Whether to remove horizontal track if scrolling is not possible/blocked and tracks are not
323 | permanent.
324 |
325 | **translateContentSizesToHolder** _`:boolean`_ = undefined
326 | Pass content's `scrollHeight` and `scrollWidth` values to the holder's `height` and `width`
327 | styles. _Not working with `native` behavior._
328 |
329 | **translateContentSizeYToHolder** _`:boolean`_ = undefined
330 | Pass content's `scrollHeight` values to the holder's `height` style. _Not working with `native`
331 | behavior._
332 |
333 | **translateContentSizeXToHolder** _`:boolean`_ = undefined
334 | Pass content's `scrollWidth` values to the holder's `width` style. _Not working with `native`
335 | behavior._
336 |
337 | **trackClickBehavior** _`:string`_ = "jump"
338 | The way scrolling behaves while user clicked the track:
339 |
340 | - _jump_ - will cause straight scroll to the respective position.
341 | - _step_ - will cause one screen step towards (like PageUp/PageDown) the clicked position.
342 |
343 | **scrollbarWidth** _`:number`_ = undefined
344 | Scrollbar width value needed to proper native scrollbars hide. While `undefined` it is detected
345 | automatically (once per module require).
346 | This prop is needed generally for testing purposes.
347 |
348 | **fallbackScrollbarWidth** _`:number`_ = 20
349 | This value will be used in case of falsy `scrollbarWidth` prop. E.g. it is used for mobile devices,
350 | because it is impossible to detect their real scrollbar width (due to their absolute positioning).
351 |
352 | **scrollTop** _`:number`_ = undefined
353 | Prop that allow you to set vertical scroll.
354 |
355 | **scrollLeft** _`:number`_ = undefined
356 | Prop that allow you to set horizontal scroll.
357 |
358 | **scrollDetectionThreshold** _`:number`_ = 100
359 | Amount of seconds after which scrolling will be treated as completed and `scrollStop` event emitted.
360 |
361 | **elementRef** _`:function(ref: Scrollbar)`_ = undefined
362 | Function that receive the scrollbar instance as 1st parameter.
363 |
364 | **renderer** _`:SFC`_ = undefined
365 | SFC used to render the holder. [More about renderers usage](#customisation).
366 |
367 | **wrapperProps** _`:object`_ = {}
368 | Here you can pass any props for wrapper, which is usually HTMLDivElement plus `elementRef` props
369 | which behaves as holder's `elementRef` prop.
370 |
371 | **contentProps** _`:object`_ = {}
372 | Here you can pass any props for content, which is usually HTMLDivElement plus `elementRef` props
373 | which behaves as holder's `elementRef` prop.
374 |
375 | **trackXProps** _`:object`_ = {}
376 | Here you can pass any props for trackX, which is usually HTMLDivElement plus `elementRef` props
377 | which behaves as holder's `elementRef` prop.
378 |
379 | **trackYProps** _`:object`_ = {}
380 | Here you can pass any props for trackY, which is usually HTMLDivElement plus `elementRef` props
381 | which behaves as holder's `elementRef` prop.
382 |
383 | **thumbXProps** _`:object`_ = {}
384 | Here you can pass any props for thumbX, which is usually HTMLDivElement plus `elementRef` props
385 | which behaves as holder's `elementRef` prop.
386 |
387 | **thumbYProps** _`:object`_ = {}
388 | Here you can pass any props for thumbY, which is usually HTMLDivElement plus `elementRef` props
389 | which behaves as holder's `elementRef` prop.
390 |
391 | **onUpdate** _`:function(scrollValues: ScrollState, prevScrollValues: ScrollState)`_ = undefined
392 | Function called each time any of scroll values changed and component performed an update. It is
393 | called after component's update.
394 |
395 | **onScroll** _`:function(scrollValues: ScrollState, prevScrollValues: ScrollState)`_ = undefined
396 | Function called each time scrollTop or scrollLeft has changed. It is called after component's update
397 | and even if scrollTop/scrollLeft has been changed through the code (not by user).
398 |
399 | **onScrollStart** _`:function(scrollValues: ScrollState)`_ = undefined
400 | Callback that called immediately when user started scrolling (no matter how, thumb dragging,
401 | keyboard, mousewheel and etc.).
402 |
403 | **onScrollStop** _`:function(scrollValues: ScrollState)`_ = undefined
404 | Callback that called after `props.scrollDetectionThreshold` milliseconds after last scroll event.
405 |
406 | ### INSTANCE PROPERTIES
407 |
408 | **eventEmitter** _`:Emittr`_
409 | Event emitter that allow you to add events handler for cases when you access Scrollbars through
410 | context
411 |
412 | **holderElement** _`:HTMLDivElement | null`_
413 | Holder DOM element reference or null if element was not rendered
414 |
415 | **wrapperElement** _`:HTMLDivElement | null`_
416 | Wrapper DOM element reference or null if element was not rendered
417 |
418 | **scrollerElement** _`:HTMLDivElement | null`_
419 | Scroller DOM element reference or null if element was not rendered
420 |
421 | **contentElement** _`:HTMLDivElement | null`_
422 | Content DOM element reference or null if element was not rendered
423 |
424 | **trackXElement** _`:HTMLDivElement | null`_
425 | Horizontal track DOM element reference or null if element was not rendered
426 |
427 | **trackYElement** _`:HTMLDivElement | null`_
428 | Vertical track DOM element reference or null if element was not rendered
429 |
430 | **thumbXElement** _`:HTMLDivElement | null`_
431 | Horizontal thumb DOM element reference or null if element was not rendered
432 |
433 | **thumbYElement** _`:HTMLDivElement | null`_
434 | Vertical thumb DOM element reference or null if element was not rendered
435 |
436 | (get|set) **scrollTop** _`:number`_
437 | Content's element scroll top
438 |
439 | (get|set) **scrollLeft** _`:number`_
440 | Content's element scroll left
441 |
442 | (get) **scrollHeight** _`:number`_
443 | Content's element scroll height
444 |
445 | (get) **scrollWidth** _`:number`_
446 | Content's element scroll width
447 |
448 | (get) **clientHeight** _`:number`_
449 | Content's element client height
450 |
451 | (get) **clientWidth** _`:number`_
452 | Content's element client width
453 |
454 | ### INSTANCE METHODS
455 |
456 | **getScrollState(force:boolean = false)** _`:plain object`_
457 | Current scroll-related values, if `force` parameter is falsy - returns cached value which updated
458 | with RAF loop
459 | Returned values:
460 |
461 | ```typescript
462 | type ScrollState = {
463 | /**
464 | * @description Content's native clientHeight parameter
465 | */
466 | clientHeight: number;
467 | /**
468 | * @description Content's native clientWidth parameter
469 | */
470 | clientWidth: number;
471 | /**
472 | * @description Content's native scrollHeight parameter
473 | */
474 | scrollHeight: number;
475 | /**
476 | * @description Content's native scrollWidth parameter
477 | */
478 | scrollWidth: number;
479 | /**
480 | * @description Content's native scrollTop parameter
481 | */
482 | scrollTop: number;
483 | /**
484 | * @description Content's native scrollLeft parameter
485 | */
486 | scrollLeft: number;
487 | /**
488 | * @description Indicates whether vertical scroll blocked via properties
489 | */
490 | scrollYBlocked: boolean;
491 | /**
492 | * @description Indicates whether horizontal scroll blocked via properties
493 | */
494 | scrollXBlocked: boolean;
495 | /**
496 | * @description Indicates whether the content overflows vertically and scrolling not blocked
497 | */
498 | scrollYPossible: boolean;
499 | /**
500 | * @description Indicates whether the content overflows horizontally and scrolling not blocked
501 | */
502 | scrollXPossible: boolean;
503 | /**
504 | * @description Indicates whether vertical track is visible
505 | */
506 | trackYVisible: boolean;
507 | /**
508 | * @description Indicates whether horizontal track is visible
509 | */
510 | trackXVisible: boolean;
511 | /**
512 | * @description Indicates whether display direction is right-to-left
513 | */
514 | isRTL?: boolean;
515 |
516 | /**
517 | * @description Pages zoom level - it affects scrollbars
518 | */
519 | zoomLevel: number;
520 | };
521 | ```
522 |
523 | **scrollToTop()** _`:this`_
524 | Scroll to the very top border of scrollable area
525 |
526 | **scrollToLeft()** _`:this`_
527 | Scroll to the very left border of scrollable area
528 |
529 | **scrollToBottom()** _`:this`_
530 | Scroll to the very bottom border of scrollable area
531 |
532 | **scrollToRight()** _`:this`_
533 | Scroll to the very right border of scrollable area
534 |
535 | **scrollTo(x?: number, y?: number)** _`:this`_
536 | Set the current scroll at given coordinates. If any value is `undefined` it'll be left as is.
537 |
538 | **centerAt(x?: number, y?: number)** _`:this`_
539 | Center viewport at given coordinates. If any value is `undefined` it'll be left as is.
540 |
--------------------------------------------------------------------------------
/karma.conf.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line unicorn/prefer-module
2 | module.exports = (cfg) => {
3 | cfg.set({
4 | browsers: ['ChromeHeadless'],
5 |
6 | singleRun: true,
7 | autoWatch: false,
8 |
9 | frameworks: ['jasmine', 'karma-typescript'],
10 | reporters: ['progress', 'karma-typescript'],
11 | preprocessors: {
12 | '**/*.ts': 'karma-typescript',
13 | '**/*.tsx': 'karma-typescript',
14 | },
15 |
16 | files: ['./src/**/*.ts', './src/**/*.tsx', './tests/**/*.spec.ts', './tests/**/*.spec.tsx'],
17 |
18 | client: {
19 | jasmine: {
20 | random: false,
21 | },
22 | },
23 |
24 | karmaTypescriptConfig: {
25 | bundlerOptions: {
26 | constants: {
27 | 'process.env': {
28 | NODE_ENV: 'production',
29 | },
30 | },
31 | },
32 | compilerOptions: {
33 | target: 'es2017',
34 | module: 'commonjs',
35 | moduleResolution: 'node',
36 | jsx: 'react',
37 | lib: ['dom', 'es2017'],
38 | },
39 | coverageOptions: {
40 | exclude: /(node_modules|tests|spec)/i,
41 | },
42 | reports: {
43 | lcovonly: {
44 | directory: './coverage',
45 | subdirectory: () => '',
46 | filename: 'lcov.info',
47 | },
48 | html: {
49 | directory: './coverage',
50 | subdirectory: () => '',
51 | },
52 | },
53 | include: ['./src/**/*', './tests/**/*'],
54 | },
55 | });
56 | };
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-scrollbars-custom",
3 | "description": "The best React custom scrollbars component",
4 | "version": "4.1.1",
5 | "funding": {
6 | "type": "patreon",
7 | "url": "https://www.patreon.com/xobotyi"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/xobotyi/react-scrollbars-custom.git"
12 | },
13 | "bugs": {
14 | "url": "https://github.com/xobotyi/react-scrollbars-custom/issues"
15 | },
16 | "homepage": "https://github.com/xobotyi/react-scrollbars-custom",
17 | "author": {
18 | "name": "Anton Zinovyev",
19 | "url": "https://github.com/xobotyi",
20 | "email": "xog3@yandex.ru"
21 | },
22 | "license": "MIT",
23 | "keywords": [
24 | "customizable",
25 | "scrollbars",
26 | "scroll",
27 | "scrollbar",
28 | "react",
29 | "component",
30 | "custom"
31 | ],
32 | "main": "dist/rsc.js",
33 | "module": "dist/rsc.esm.js",
34 | "esnext": "dist/rsc.next.esm.js",
35 | "types": "dist/types/index.d.ts",
36 | "files": [
37 | "dist"
38 | ],
39 | "peerDependencies": {
40 | "react": ">=16.0.0"
41 | },
42 | "dependencies": {
43 | "cnbuilder": "^3.1.0",
44 | "react-draggable": "^4.4.5",
45 | "zoom-level": "^2.5.0"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.18.10",
49 | "@babel/preset-env": "^7.18.10",
50 | "@jamesacarr/eslint-formatter-github-actions": "^0.2.0",
51 | "@react-hookz/eslint-config": "^1.7.1",
52 | "@semantic-release/changelog": "^6.0.1",
53 | "@semantic-release/git": "^10.0.1",
54 | "@semantic-release/github": "^8.0.5",
55 | "@types/jasmine": "^4.0.3",
56 | "@types/karma": "^6.3.3",
57 | "@types/react": "^17",
58 | "@types/react-dom": "^17",
59 | "cross-env": "^7.0.3",
60 | "jasmine-core": "^5.0.0",
61 | "karma": "^6.4.0",
62 | "karma-chrome-launcher": "^3.1.1",
63 | "karma-coverage": "^2.2.0",
64 | "karma-jasmine": "^5.1.0",
65 | "karma-typescript": "^5.5.3",
66 | "lint-staged": "^13.0.3",
67 | "prettier": "^2.7.1",
68 | "react": "^17",
69 | "react-dom": "^17",
70 | "rimraf": "^5.0.0",
71 | "rollup": "^2.77.0",
72 | "rollup-plugin-babel": "^4.4.0",
73 | "rollup-plugin-typescript2": "^0.35.0",
74 | "semantic-release": "^19.0.3",
75 | "simulant": "^0.2.2",
76 | "tslib": "^2.4.0",
77 | "typescript": "^5.0.4"
78 | },
79 | "lint-staged": {
80 | "*.{js,jsx,ts,tsx,md,mdx}": "eslint --fix"
81 | },
82 | "scripts": {
83 | "lint": "eslint ./ --ext ts,js,tsx,jsx,md,mdx",
84 | "lint:fix": "yarn lint --fix",
85 | "devserver": "cd ./testbench && npm i && npm run devserver",
86 | "build": "rimraf ./dist && rollup --config",
87 | "test": "cross-env NODE_ENV=production karma start"
88 | },
89 | "release": {
90 | "plugins": [
91 | "@semantic-release/commit-analyzer",
92 | "@semantic-release/release-notes-generator",
93 | "@semantic-release/changelog",
94 | "@semantic-release/npm",
95 | "@semantic-release/git",
96 | "@semantic-release/github"
97 | ]
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel';
2 | import ts from 'rollup-plugin-typescript2';
3 | import pkg from './package.json';
4 |
5 | const ownKeys = Object.getOwnPropertyNames;
6 | const externalDependencies = [
7 | ...new Set([...ownKeys(pkg.peerDependencies), ...ownKeys(pkg.dependencies)]),
8 | ];
9 |
10 | export default [
11 | {
12 | input: './src/index.ts',
13 | external: externalDependencies,
14 |
15 | output: [
16 | {
17 | file: pkg.esnext,
18 | format: 'es',
19 | exports: 'named',
20 | },
21 | ],
22 |
23 | plugins: [
24 | ts({
25 | clean: true,
26 | useTsconfigDeclarationDir: true,
27 | tsconfigOverride: {
28 | compilerOptions: {
29 | module: 'esnext',
30 | // ToDo: FIXME! sadly rollup do not handle optional chaining yet
31 | target: 'es2019',
32 | declaration: true,
33 | declarationDir: `${__dirname}/dist/types`,
34 | },
35 | },
36 | }),
37 | ],
38 | },
39 | {
40 | input: './src/index.ts',
41 | external: externalDependencies,
42 |
43 | output: [
44 | {
45 | file: pkg.main,
46 | format: 'cjs',
47 | sourcemap: true,
48 | exports: 'named',
49 | },
50 | {
51 | file: pkg.module,
52 | format: 'esm',
53 | exports: 'named',
54 | },
55 | ],
56 |
57 | plugins: [
58 | ts({
59 | clean: true,
60 | tsconfigOverride: {
61 | compilerOptions: {
62 | module: 'esnext',
63 | target: 'es5',
64 | declaration: false,
65 | },
66 | },
67 | }),
68 | babel({
69 | babelrc: false,
70 | exclude: 'node_modules/**',
71 | extensions: ['.ts', '.tsx', '.js', '.jsx'],
72 | presets: [
73 | [
74 | '@babel/preset-env',
75 | {
76 | targets: {
77 | ie: '9',
78 | },
79 | },
80 | ],
81 | ],
82 | }),
83 | ],
84 | },
85 | ];
86 |
--------------------------------------------------------------------------------
/src/Emittr.ts:
--------------------------------------------------------------------------------
1 | import { isFun, isNum, isUndef } from './util';
2 |
3 | type EventHandler = (...args: any[]) => void;
4 | type OnceHandler = OnceHandlerState & { (...args: any[]): void };
5 | type EventHandlersList = (OnceHandler | EventHandler)[];
6 | type EmitterEventHandlers = { [key: string]: EventHandlersList };
7 | type OnceHandlerState = {
8 | fired: boolean;
9 | handler: EventHandler;
10 | wrappedHandler?: OnceHandler;
11 | emitter: Emittr;
12 | event: string;
13 | };
14 |
15 | export default class Emittr {
16 | private _handlers: EmitterEventHandlers;
17 |
18 | private _maxHandlers: number;
19 |
20 | constructor(maxHandlers = 10) {
21 | this.setMaxHandlers(maxHandlers);
22 | this._handlers = Object.create(null);
23 | }
24 |
25 | private static _callEventHandlers(emitter: Emittr, handlers: EventHandlersList, args: any[]) {
26 | if (!handlers.length) {
27 | return;
28 | }
29 | if (handlers.length === 1) {
30 | Reflect.apply(handlers[0], emitter, args);
31 | return;
32 | }
33 | handlers = [...handlers];
34 | let idx;
35 | for (idx = 0; idx < handlers.length; idx++) {
36 | Reflect.apply(handlers[idx], emitter, args);
37 | }
38 | }
39 |
40 | private static _addHandler = (
41 | emitter: Emittr,
42 | name: string,
43 | handler: EventHandler,
44 | prepend = false
45 | ): Emittr => {
46 | if (!isFun(handler)) {
47 | throw new TypeError(`Expected event handler to be a function, got ${typeof handler}`);
48 | }
49 | emitter._handlers[name] = emitter._handlers[name] || [];
50 | emitter.emit('addHandler', name, handler);
51 |
52 | if (prepend) {
53 | emitter._handlers[name].unshift(handler);
54 | } else {
55 | emitter._handlers[name].push(handler);
56 | }
57 |
58 | return emitter;
59 | };
60 |
61 | private static _onceWrapper = function _onceWrapper(...args: any[]) {
62 | if (!this.fired) {
63 | this.fired = true;
64 | this.emitter.off(this.event, this.wrappedHandler);
65 | Reflect.apply(this.handler, this.emitter, args);
66 | }
67 | };
68 |
69 | private static _removeHandler = (
70 | emitter: Emittr,
71 | name: string,
72 | handler: EventHandler
73 | ): Emittr => {
74 | if (!isFun(handler)) {
75 | throw new TypeError(`Expected event handler to be a function, got ${typeof handler}`);
76 | }
77 | if (isUndef(emitter._handlers[name]) || !emitter._handlers[name].length) {
78 | return emitter;
79 | }
80 | let idx = -1;
81 | if (emitter._handlers[name].length === 1) {
82 | if (
83 | emitter._handlers[name][0] === handler ||
84 | (emitter._handlers[name][0] as OnceHandler).handler === handler
85 | ) {
86 | idx = 0;
87 | handler = (emitter._handlers[name][0] as OnceHandler).handler || emitter._handlers[name][0];
88 | }
89 | } else {
90 | for (idx = emitter._handlers[name].length - 1; idx >= 0; idx--) {
91 | if (
92 | emitter._handlers[name][idx] === handler ||
93 | (emitter._handlers[name][idx] as OnceHandler).handler === handler
94 | ) {
95 | handler =
96 | (emitter._handlers[name][idx] as OnceHandler).handler || emitter._handlers[name][idx];
97 | break;
98 | }
99 | }
100 | }
101 | if (idx === -1) {
102 | return emitter;
103 | }
104 |
105 | if (idx === 0) {
106 | emitter._handlers[name].shift();
107 | } else {
108 | emitter._handlers[name].splice(idx, 1);
109 | }
110 |
111 | emitter.emit('removeHandler', name, handler);
112 | return emitter;
113 | };
114 |
115 | setMaxHandlers(count: number): this {
116 | if (!isNum(count) || count <= 0) {
117 | throw new TypeError(
118 | `Expected maxHandlers to be a positive number, got '${count}' of type ${typeof count}`
119 | );
120 | }
121 | this._maxHandlers = count;
122 | return this;
123 | }
124 |
125 | getMaxHandlers(): number {
126 | return this._maxHandlers;
127 | }
128 |
129 | public emit(name: string, ...args: any[]): boolean {
130 | if (typeof this._handlers[name] !== 'object' || !Array.isArray(this._handlers[name])) {
131 | return false;
132 | }
133 | Emittr._callEventHandlers(this, this._handlers[name], args);
134 | return true;
135 | }
136 |
137 | public on(name: string, handler: EventHandler): this {
138 | Emittr._addHandler(this, name, handler);
139 | return this;
140 | }
141 |
142 | public prependOn(name: string, handler: EventHandler): this {
143 | Emittr._addHandler(this, name, handler, true);
144 | return this;
145 | }
146 |
147 | public once(name: string, handler: EventHandler): this {
148 | if (!isFun(handler)) {
149 | throw new TypeError(`Expected event handler to be a function, got ${typeof handler}`);
150 | }
151 | Emittr._addHandler(this, name, this._wrapOnceHandler(name, handler));
152 | return this;
153 | }
154 |
155 | public prependOnce(name: string, handler: EventHandler): this {
156 | if (!isFun(handler)) {
157 | throw new TypeError(`Expected event handler to be a function, got ${typeof handler}`);
158 | }
159 | Emittr._addHandler(this, name, this._wrapOnceHandler(name, handler), true);
160 | return this;
161 | }
162 |
163 | public off(name: string, handler: EventHandler): this {
164 | Emittr._removeHandler(this, name, handler);
165 | return this;
166 | }
167 |
168 | public removeAllHandlers(): this {
169 | const handlers = this._handlers;
170 | this._handlers = Object.create(null);
171 | const removeHandlers = handlers.removeHandler;
172 | delete handlers.removeHandler;
173 | let idx;
174 | let eventName;
175 | // eslint-disable-next-line guard-for-in,no-restricted-syntax
176 | for (eventName in handlers) {
177 | for (idx = handlers[eventName].length - 1; idx >= 0; idx--) {
178 | Emittr._callEventHandlers(this, removeHandlers, [
179 | eventName,
180 | (handlers[eventName][idx] as OnceHandler).handler || handlers[eventName][idx],
181 | ]);
182 | }
183 | }
184 | return this;
185 | }
186 |
187 | private _wrapOnceHandler(name: string, handler: EventHandler): OnceHandler {
188 | const onceState: OnceHandlerState = {
189 | fired: false,
190 | handler,
191 | wrappedHandler: undefined,
192 | emitter: this,
193 | event: name,
194 | };
195 | const wrappedHandler: OnceHandler = Emittr._onceWrapper.bind(onceState);
196 | onceState.wrappedHandler = wrappedHandler;
197 | wrappedHandler.handler = handler;
198 | wrappedHandler.event = name;
199 | return wrappedHandler;
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/src/Loop.ts:
--------------------------------------------------------------------------------
1 | interface UpdatableItem {
2 | _unmounted?: boolean;
3 | update: () => any;
4 | }
5 |
6 | export class RAFLoop {
7 | /**
8 | * @description List of targets to update
9 | */
10 | private readonly targets: UpdatableItem[] = [];
11 |
12 | /**
13 | * @description ID of requested animation frame. Valuable only if loop is active and has items to iterate.
14 | */
15 | private animationFrameID = 0;
16 |
17 | /**
18 | * @description Loop's state.
19 | */
20 | private _isActive = false;
21 |
22 | /**
23 | * @description Loop's state.
24 | */
25 | public get isActive(): boolean {
26 | return this._isActive;
27 | }
28 |
29 | /**
30 | * @description Start the loop if it wasn't yet.
31 | */
32 | public start = (): this => {
33 | if (!this._isActive && this.targets.length) {
34 | this._isActive = true;
35 |
36 | if (this.animationFrameID) cancelAnimationFrame(this.animationFrameID);
37 | this.animationFrameID = requestAnimationFrame(this.rafCallback);
38 | }
39 |
40 | return this;
41 | };
42 |
43 | /**
44 | * @description Stop the loop if is was active.
45 | */
46 | public stop = (): this => {
47 | if (this._isActive) {
48 | this._isActive = false;
49 |
50 | if (this.animationFrameID) cancelAnimationFrame(this.animationFrameID);
51 | this.animationFrameID = 0;
52 | }
53 |
54 | return this;
55 | };
56 |
57 | /**
58 | * @description Add target to the iteration list if it's not there.
59 | */
60 | public addTarget = (target: UpdatableItem, silent = false): this => {
61 | if (!this.targets.includes(target)) {
62 | this.targets.push(target);
63 |
64 | if (this.targets.length === 1 && !silent) this.start();
65 | }
66 |
67 | return this;
68 | };
69 |
70 | /**
71 | * @description Remove target from iteration list if it was there.
72 | */
73 | public removeTarget = (target: UpdatableItem): this => {
74 | const idx = this.targets.indexOf(target);
75 |
76 | if (idx !== -1) {
77 | this.targets.splice(idx, 1);
78 |
79 | if (this.targets.length === 0) this.stop();
80 | }
81 |
82 | return this;
83 | };
84 |
85 | /**
86 | * @description Callback that called each animation frame.
87 | */
88 | private rafCallback = (): number => {
89 | if (!this._isActive) {
90 | return 0;
91 | }
92 |
93 | for (let i = 0; i < this.targets.length; i++) {
94 | if (!this.targets[i]._unmounted) this.targets[i].update();
95 | }
96 |
97 | this.animationFrameID = requestAnimationFrame(this.rafCallback);
98 | return this.animationFrameID;
99 | };
100 | }
101 |
102 | export default new RAFLoop();
103 |
--------------------------------------------------------------------------------
/src/Scrollbar.tsx:
--------------------------------------------------------------------------------
1 | import { cnb } from 'cnbuilder';
2 | import * as React from 'react';
3 | import { DraggableData } from 'react-draggable';
4 | import { zoomLevel } from 'zoom-level';
5 | import Emittr from './Emittr';
6 | import Loop from './Loop';
7 | import ScrollbarThumb, { ScrollbarThumbProps } from './ScrollbarThumb';
8 | import ScrollbarTrack, {
9 | ScrollbarTrackClickParameters,
10 | ScrollbarTrackProps,
11 | } from './ScrollbarTrack';
12 | import defaultStyle from './style';
13 | import {
14 | AXIS_DIRECTION,
15 | ElementPropsWithElementRefAndRenderer,
16 | ScrollState,
17 | TRACK_CLICK_BEHAVIOR,
18 | } from './types';
19 | import * as util from './util';
20 | import { isBrowser, renderDivWithRenderer } from './util';
21 |
22 | let pageZoomLevel: number = isBrowser ? zoomLevel() : 1;
23 | if (isBrowser) {
24 | window.addEventListener(
25 | 'resize',
26 | () => {
27 | pageZoomLevel = zoomLevel();
28 | },
29 | { passive: true }
30 | );
31 | }
32 |
33 | export type ScrollbarProps = ElementPropsWithElementRefAndRenderer & {
34 | createContext?: boolean;
35 |
36 | rtl?: boolean;
37 |
38 | momentum?: boolean;
39 | native?: boolean;
40 | mobileNative?: boolean;
41 |
42 | noScrollX?: boolean;
43 | noScrollY?: boolean;
44 | noScroll?: boolean;
45 |
46 | permanentTrackX?: boolean;
47 | permanentTrackY?: boolean;
48 | permanentTracks?: boolean;
49 |
50 | removeTracksWhenNotUsed?: boolean;
51 | removeTrackYWhenNotUsed?: boolean;
52 | removeTrackXWhenNotUsed?: boolean;
53 |
54 | minimalThumbSize?: number;
55 | maximalThumbSize?: number;
56 | minimalThumbXSize?: number;
57 | maximalThumbXSize?: number;
58 | minimalThumbYSize?: number;
59 | maximalThumbYSize?: number;
60 |
61 | scrollbarWidth?: number;
62 | fallbackScrollbarWidth?: number;
63 |
64 | scrollTop?: number;
65 | scrollLeft?: number;
66 | scrollDetectionThreshold?: number;
67 |
68 | translateContentSizesToHolder?: boolean;
69 | translateContentSizeYToHolder?: boolean;
70 | translateContentSizeXToHolder?: boolean;
71 |
72 | noDefaultStyles?: boolean;
73 |
74 | disableTracksMousewheelScrolling?: boolean;
75 | disableTrackXMousewheelScrolling?: boolean;
76 | disableTrackYMousewheelScrolling?: boolean;
77 |
78 | disableTracksWidthCompensation?: boolean;
79 | disableTrackXWidthCompensation?: boolean;
80 | disableTrackYWidthCompensation?: boolean;
81 |
82 | trackClickBehavior?: TRACK_CLICK_BEHAVIOR;
83 |
84 | wrapperProps?: ElementPropsWithElementRefAndRenderer;
85 | scrollerProps?: ElementPropsWithElementRefAndRenderer;
86 | contentProps?: ElementPropsWithElementRefAndRenderer;
87 |
88 | trackXProps?: Pick>;
89 | trackYProps?: Pick>;
90 |
91 | thumbXProps?: Pick>;
92 | thumbYProps?: Pick>;
93 |
94 | onUpdate?: (scrollValues: ScrollState, prevScrollState: ScrollState) => void;
95 | onScroll?: (scrollValues: ScrollState, prevScrollState: ScrollState) => void;
96 | onScrollStart?: (scrollValues: ScrollState) => void;
97 | onScrollStop?: (scrollValues: ScrollState) => void;
98 | };
99 |
100 | export type ScrollbarState = {
101 | trackXVisible: boolean;
102 | trackYVisible: boolean;
103 | isRTL?: boolean;
104 | };
105 |
106 | export type ScrollbarContextValue = { parentScrollbar: Scrollbar | null };
107 |
108 | export const ScrollbarContext: React.Context = React.createContext({
109 | parentScrollbar: null,
110 | } as ScrollbarContextValue);
111 |
112 | export default class Scrollbar extends React.Component {
113 | // eslint-disable-next-line react/static-property-placement
114 | static contextType = ScrollbarContext;
115 |
116 | // eslint-disable-next-line react/static-property-placement
117 | static defaultProps = {
118 | momentum: true,
119 |
120 | minimalThumbSize: 30,
121 |
122 | fallbackScrollbarWidth: 20,
123 |
124 | trackClickBehavior: TRACK_CLICK_BEHAVIOR.JUMP,
125 |
126 | scrollDetectionThreshold: 100,
127 |
128 | wrapperProps: {},
129 | scrollerProps: {},
130 | contentProps: {},
131 | trackXProps: {},
132 | trackYProps: {},
133 | thumbXProps: {},
134 | thumbYProps: {},
135 | };
136 |
137 | /**
138 | * @description UUID identifying scrollbar instance
139 | */
140 | public readonly id: string;
141 |
142 | /**
143 | * @description Reference to the holder HTMLDivElement or null if it wasn't rendered or native property is true
144 | */
145 | public holderElement: HTMLDivElement | null;
146 |
147 | /**
148 | * @description Reference to the wrapper HTMLDivElement or null if it wasn't rendered or native property is true
149 | */
150 | public wrapperElement: HTMLDivElement | null;
151 |
152 | /**
153 | * @description Reference to the HTMLDivElement that actually has browser's scrollbars
154 | */
155 | public scrollerElement: HTMLDivElement | null;
156 |
157 | /**
158 | * @description Reference to the content HTMLDivElement that contains component's children (and has browser's scrollbars)
159 | */
160 | public contentElement: HTMLDivElement | null;
161 |
162 | /**
163 | * @description Reference to the horizontal track HTMLDivElement or null if it wasn't rendered
164 | */
165 | public trackXElement: HTMLDivElement | null;
166 |
167 | /**
168 | * @description Reference to the vertical track HTMLDivElement or null if it wasn't rendered
169 | */
170 | public trackYElement: HTMLDivElement | null;
171 |
172 | /**
173 | * @description Reference to the horizontal thumb HTMLDivElement or null if it wasn't rendered
174 | */
175 | public thumbXElement: HTMLDivElement | null;
176 |
177 | /**
178 | * @description Reference to the vertical thumb HTMLDivElement or null if it wasn't rendered
179 | */
180 | public thumbYElement: HTMLDivElement | null;
181 |
182 | public readonly eventEmitter: Emittr;
183 |
184 | /**
185 | * @description Current ScrollState (cached)
186 | */
187 | private scrollValues: ScrollState;
188 |
189 | private _scrollDetectionTO: number | null;
190 |
191 | constructor(props) {
192 | super(props);
193 |
194 | this.state = {
195 | trackXVisible: false,
196 | trackYVisible: false,
197 | isRTL: props.rtl,
198 | };
199 |
200 | this.scrollValues = this.getScrollState(true);
201 |
202 | this.eventEmitter = new Emittr(15);
203 |
204 | if (props.onUpdate) this.eventEmitter.on('update', props.onUpdate);
205 | if (props.onScroll) this.eventEmitter.on('scroll', props.onScroll);
206 | if (props.onScrollStart) this.eventEmitter.on('scrollStart', props.onScrollStart);
207 | if (props.onScrollStop) this.eventEmitter.on('scrollStop', props.onScrollStop);
208 |
209 | this.id = util.uuid();
210 | }
211 |
212 | // eslint-disable-next-line react/sort-comp
213 | get scrollTop() {
214 | if (this.scrollerElement) {
215 | return this.scrollerElement.scrollTop;
216 | }
217 |
218 | return 0;
219 | }
220 |
221 | set scrollTop(top) {
222 | if (this.scrollerElement) {
223 | this.scrollerElement.scrollTop = top;
224 | this.update();
225 | }
226 | }
227 |
228 | get scrollLeft() {
229 | if (this.scrollerElement) {
230 | return this.scrollerElement.scrollLeft;
231 | }
232 |
233 | return 0;
234 | }
235 |
236 | set scrollLeft(left) {
237 | if (this.scrollerElement) {
238 | this.scrollerElement.scrollLeft = left;
239 | }
240 | }
241 |
242 | get scrollHeight() {
243 | if (this.scrollerElement) {
244 | return this.scrollerElement.scrollHeight;
245 | }
246 |
247 | return 0;
248 | }
249 |
250 | get scrollWidth() {
251 | if (this.scrollerElement) {
252 | return this.scrollerElement.scrollWidth;
253 | }
254 |
255 | return 0;
256 | }
257 |
258 | get clientHeight() {
259 | if (this.scrollerElement) {
260 | return this.scrollerElement.clientHeight;
261 | }
262 |
263 | return 0;
264 | }
265 |
266 | get clientWidth() {
267 | if (this.scrollerElement) {
268 | return this.scrollerElement.clientWidth;
269 | }
270 |
271 | return 0;
272 | }
273 |
274 | // eslint-disable-next-line react/sort-comp
275 | public static calculateStyles(
276 | props: ScrollbarProps,
277 | state: ScrollbarState,
278 | scrollValues,
279 | scrollbarWidth: number
280 | ) {
281 | const useDefaultStyles = !props.noDefaultStyles;
282 |
283 | return {
284 | holder: {
285 | ...(useDefaultStyles && defaultStyle.holder),
286 | position: 'relative',
287 | ...props.style,
288 | } as React.CSSProperties,
289 | wrapper: {
290 | ...(useDefaultStyles && {
291 | ...defaultStyle.wrapper,
292 | ...(!props.disableTracksWidthCompensation &&
293 | !props.disableTrackYWidthCompensation && {
294 | [state.isRTL ? 'left' : 'right']: state.trackYVisible ? 10 : 0,
295 | }),
296 | ...(!props.disableTracksWidthCompensation &&
297 | !props.disableTrackXWidthCompensation && {
298 | bottom: state.trackXVisible ? 10 : 0,
299 | }),
300 | }),
301 | ...props.wrapperProps!.style,
302 | position: 'absolute',
303 | overflow: 'hidden',
304 | } as React.CSSProperties,
305 | content: {
306 | ...(useDefaultStyles && defaultStyle.content),
307 | ...(props.translateContentSizesToHolder ||
308 | props.translateContentSizeYToHolder ||
309 | props.translateContentSizeXToHolder
310 | ? {
311 | display: 'table-cell',
312 | }
313 | : {
314 | padding: 0.05, // needed to disable margin collapsing without flexboxes, other possible solutions here: https://stackoverflow.com/questions/19718634/how-to-disable-margin-collapsing
315 | }),
316 | ...(useDefaultStyles &&
317 | !(props.translateContentSizesToHolder || props.translateContentSizeYToHolder) && {
318 | minHeight: '100%',
319 | }),
320 | ...(useDefaultStyles &&
321 | !(props.translateContentSizesToHolder || props.translateContentSizeXToHolder) && {
322 | minWidth: '100%',
323 | }),
324 | ...props.contentProps!.style,
325 | } as React.CSSProperties,
326 | scroller: {
327 | position: 'absolute',
328 | top: 0,
329 | left: 0,
330 | bottom: 0,
331 | right: 0,
332 |
333 | paddingBottom:
334 | !scrollbarWidth && scrollValues.scrollXPossible
335 | ? props.fallbackScrollbarWidth
336 | : undefined,
337 |
338 | [state.isRTL ? 'paddingLeft' : 'paddingRight']:
339 | !scrollbarWidth && scrollValues.scrollYPossible
340 | ? props.fallbackScrollbarWidth
341 | : undefined,
342 |
343 | ...props.scrollerProps!.style,
344 |
345 | ...(!util.isUndef(props.rtl) && {
346 | direction: props.rtl ? 'rtl' : 'ltr',
347 | }),
348 |
349 | ...(props.momentum && { WebkitOverflowScrolling: 'touch' }),
350 |
351 | overflowY: scrollValues.scrollYPossible ? 'scroll' : 'hidden',
352 | overflowX: scrollValues.scrollXPossible ? 'scroll' : 'hidden',
353 |
354 | marginBottom: scrollValues.scrollXPossible
355 | ? -(scrollbarWidth || props.fallbackScrollbarWidth!) -
356 | Number(scrollValues.zoomLevel !== 1)
357 | : undefined,
358 | [state.isRTL ? 'marginLeft' : 'marginRight']: scrollValues.scrollYPossible
359 | ? -(scrollbarWidth || props.fallbackScrollbarWidth!) -
360 | Number(scrollValues.zoomLevel !== 1)
361 | : undefined,
362 | } as React.CSSProperties,
363 | trackX: {
364 | ...(useDefaultStyles && defaultStyle.track.common),
365 | ...(useDefaultStyles && defaultStyle.track.x),
366 | ...props.trackXProps!.style,
367 | ...(!state.trackXVisible && { display: 'none' }),
368 | } as React.CSSProperties,
369 | trackY: {
370 | ...(useDefaultStyles && defaultStyle.track.common),
371 | ...(useDefaultStyles && defaultStyle.track.y),
372 | ...(useDefaultStyles && { [state.isRTL ? 'left' : 'right']: 0 }),
373 | ...props.trackYProps!.style,
374 | ...(!state.trackYVisible && { display: 'none' }),
375 | } as React.CSSProperties,
376 | thumbX: {
377 | ...(useDefaultStyles && defaultStyle.thumb.common),
378 | ...(useDefaultStyles && defaultStyle.thumb.x),
379 | ...props.thumbXProps!.style,
380 | } as React.CSSProperties,
381 | thumbY: {
382 | ...(useDefaultStyles && defaultStyle.thumb.common),
383 | ...(useDefaultStyles && defaultStyle.thumb.y),
384 | ...props.thumbYProps!.style,
385 | } as React.CSSProperties,
386 | };
387 | }
388 |
389 | public componentDidMount(): void {
390 | if (!this.scrollerElement) {
391 | this.setState(() => {
392 | throw new Error(
393 | "scroller element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
394 | );
395 | });
396 | return;
397 | }
398 |
399 | if (!this.contentElement) {
400 | this.setState(() => {
401 | throw new Error(
402 | "content element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
403 | );
404 | });
405 | return;
406 | }
407 |
408 | const { props } = this;
409 |
410 | if (!props.native && !props.mobileNative) {
411 | // ToDo: move native state to the state so it can be synchronized
412 | if (!this.holderElement) {
413 | this.setState(() => {
414 | throw new Error(
415 | "holder element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
416 | );
417 | });
418 | return;
419 | }
420 |
421 | if (!this.wrapperElement) {
422 | this.setState(() => {
423 | throw new Error(
424 | "wrapper element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
425 | );
426 | });
427 | return;
428 | }
429 | }
430 |
431 | Loop.addTarget(this);
432 |
433 | if (!util.isUndef(props.scrollTop)) {
434 | this.scrollerElement.scrollTop = props.scrollTop!;
435 | }
436 |
437 | if (!util.isUndef(props.scrollLeft)) {
438 | this.scrollerElement.scrollLeft = props.scrollLeft!;
439 | }
440 |
441 | this.update(true);
442 | }
443 |
444 | public componentWillUnmount(): void {
445 | Loop.removeTarget(this);
446 | }
447 |
448 | public componentDidUpdate(
449 | prevProps: Readonly,
450 | prevState: Readonly
451 | ): void {
452 | if (!this.scrollerElement) {
453 | return;
454 | }
455 |
456 | const { props } = this;
457 |
458 | if (props.rtl !== prevProps.rtl && props.rtl !== this.state.isRTL) {
459 | this.setState({ isRTL: props.rtl });
460 | }
461 |
462 | if (this.state.isRTL !== prevState.isRTL) {
463 | this.update();
464 | }
465 |
466 | if (!util.isUndef(props.scrollTop) && props.scrollTop !== this.scrollerElement.scrollTop) {
467 | this.scrollerElement.scrollTop = props.scrollTop!;
468 | }
469 |
470 | if (!util.isUndef(props.scrollLeft) && props.scrollLeft !== this.scrollerElement.scrollLeft) {
471 | this.scrollerElement.scrollLeft = props.scrollLeft!;
472 | }
473 |
474 | if (prevProps.onUpdate !== props.onUpdate) {
475 | if (prevProps.onUpdate) this.eventEmitter.off('update', prevProps.onUpdate);
476 | if (props.onUpdate) this.eventEmitter.on('update', props.onUpdate);
477 | }
478 |
479 | if (prevProps.onScroll !== props.onScroll) {
480 | if (prevProps.onScroll) this.eventEmitter.off('scroll', prevProps.onScroll);
481 | if (props.onScroll) this.eventEmitter.on('scroll', props.onScroll);
482 | }
483 |
484 | if (prevProps.onScrollStart !== props.onScrollStart) {
485 | if (prevProps.onScrollStart) this.eventEmitter.off('scrollStart', prevProps.onScrollStart);
486 | if (props.onScrollStart) this.eventEmitter.on('scrollStart', props.onScrollStart);
487 | }
488 |
489 | if (prevProps.onScrollStop !== props.onScrollStop) {
490 | if (prevProps.onScrollStop) this.eventEmitter.off('scrollStop', prevProps.onScrollStop);
491 | if (props.onScrollStop) this.eventEmitter.on('scrollStop', props.onScrollStop);
492 | }
493 | }
494 |
495 | /**
496 | * @description Get current scroll-related values.
497 | * If force if truthy - will recalculate them instead of returning cached values.
498 | *
499 | * @return ScrollState
500 | */
501 | public getScrollState = (force = false): ScrollState => {
502 | if (this.scrollValues && !force) {
503 | return { ...this.scrollValues };
504 | }
505 |
506 | const scrollState: ScrollState = {
507 | clientHeight: 0,
508 | clientWidth: 0,
509 | contentScrollHeight: 0,
510 | contentScrollWidth: 0,
511 | scrollHeight: 0,
512 | scrollWidth: 0,
513 | scrollTop: 0,
514 | scrollLeft: 0,
515 | scrollYBlocked: false,
516 | scrollXBlocked: false,
517 | scrollYPossible: false,
518 | scrollXPossible: false,
519 | trackYVisible: false,
520 | trackXVisible: false,
521 | zoomLevel: pageZoomLevel * 1,
522 | isRTL: undefined,
523 | };
524 |
525 | const { props } = this;
526 |
527 | scrollState.isRTL = this.state.isRTL;
528 |
529 | scrollState.scrollYBlocked = props.noScroll! || props.noScrollY!;
530 | scrollState.scrollXBlocked = props.noScroll! || props.noScrollX!;
531 |
532 | if (this.scrollerElement) {
533 | scrollState.clientHeight = this.scrollerElement.clientHeight;
534 | scrollState.clientWidth = this.scrollerElement.clientWidth;
535 |
536 | scrollState.scrollHeight = this.scrollerElement.scrollHeight;
537 | scrollState.scrollWidth = this.scrollerElement.scrollWidth;
538 | scrollState.scrollTop = this.scrollerElement.scrollTop;
539 | scrollState.scrollLeft = this.scrollerElement.scrollLeft;
540 |
541 | scrollState.scrollYPossible =
542 | !scrollState.scrollYBlocked && scrollState.scrollHeight > scrollState.clientHeight;
543 | scrollState.scrollXPossible =
544 | !scrollState.scrollXBlocked && scrollState.scrollWidth > scrollState.clientWidth;
545 |
546 | scrollState.trackYVisible =
547 | scrollState.scrollYPossible || props.permanentTracks! || props.permanentTrackY!;
548 | scrollState.trackXVisible =
549 | scrollState.scrollXPossible || props.permanentTracks! || props.permanentTrackX!;
550 | }
551 |
552 | if (this.contentElement) {
553 | scrollState.contentScrollHeight = this.contentElement.scrollHeight;
554 | scrollState.contentScrollWidth = this.contentElement.scrollWidth;
555 | }
556 |
557 | return scrollState;
558 | };
559 |
560 | /**
561 | * @description Scroll to top border
562 | */
563 | public scrollToTop = (): this => {
564 | if (this.scrollerElement) {
565 | this.scrollerElement.scrollTop = 0;
566 | }
567 |
568 | return this;
569 | };
570 |
571 | /**
572 | * @description Scroll to left border
573 | */
574 | public scrollToLeft = (): this => {
575 | if (this.scrollerElement) {
576 | this.scrollerElement.scrollLeft = 0;
577 | }
578 |
579 | return this;
580 | };
581 |
582 | /**
583 | * @description Scroll to bottom border
584 | */
585 | public scrollToBottom = (): this => {
586 | if (this.scrollerElement) {
587 | this.scrollerElement.scrollTop =
588 | this.scrollerElement.scrollHeight - this.scrollerElement.clientHeight;
589 | }
590 |
591 | return this;
592 | };
593 |
594 | /**
595 | * @description Scroll to right border
596 | */
597 | public scrollToRight = (): this => {
598 | if (this.scrollerElement) {
599 | this.scrollerElement.scrollLeft =
600 | this.scrollerElement.scrollWidth - this.scrollerElement.clientWidth;
601 | }
602 |
603 | return this;
604 | };
605 |
606 | /**
607 | * @description Set the scrolls at given coordinates.
608 | * If coordinate is undefined - current scroll value will persist.
609 | */
610 | public scrollTo = (x?: number, y?: number): this => {
611 | if (this.scrollerElement) {
612 | if (util.isNum(x)) this.scrollerElement.scrollLeft = x!;
613 | if (util.isNum(y)) this.scrollerElement.scrollTop = y!;
614 | }
615 |
616 | return this;
617 | };
618 |
619 | /**
620 | * @description Center the viewport at given coordinates.
621 | * If coordinate is undefined - current scroll value will persist.
622 | */
623 | public centerAt = (x?: number, y?: number): this => {
624 | if (this.scrollerElement) {
625 | if (util.isNum(x)) this.scrollerElement.scrollLeft = x - this.scrollerElement.clientWidth / 2;
626 | if (util.isNum(y)) this.scrollerElement.scrollTop = y - this.scrollerElement.clientHeight / 2;
627 | }
628 |
629 | return this;
630 | };
631 |
632 | public update = (force = false): ScrollState | void => {
633 | if (!this.scrollerElement) {
634 | return;
635 | }
636 |
637 | // autodetect direction if not defined
638 | if (util.isUndef(this.state.isRTL)) {
639 | this.setState({
640 | isRTL: getComputedStyle(this.scrollerElement).direction === 'rtl',
641 | });
642 |
643 | return this.getScrollState();
644 | }
645 |
646 | const scrollState: ScrollState = this.getScrollState(true);
647 | const prevScrollState: ScrollState = { ...this.scrollValues };
648 | const { props } = this;
649 |
650 | let bitmask = 0;
651 |
652 | if (!force) {
653 | if (prevScrollState.clientHeight !== scrollState.clientHeight) bitmask |= Math.trunc(1);
654 | if (prevScrollState.clientWidth !== scrollState.clientWidth) bitmask |= 1 << 1;
655 | if (prevScrollState.scrollHeight !== scrollState.scrollHeight) bitmask |= 1 << 2;
656 | if (prevScrollState.scrollWidth !== scrollState.scrollWidth) bitmask |= 1 << 3;
657 | if (prevScrollState.scrollTop !== scrollState.scrollTop) bitmask |= 1 << 4;
658 | if (prevScrollState.scrollLeft !== scrollState.scrollLeft) bitmask |= 1 << 5;
659 | if (prevScrollState.scrollYBlocked !== scrollState.scrollYBlocked) bitmask |= 1 << 6;
660 | if (prevScrollState.scrollXBlocked !== scrollState.scrollXBlocked) bitmask |= 1 << 7;
661 | if (prevScrollState.scrollYPossible !== scrollState.scrollYPossible) bitmask |= 1 << 8;
662 | if (prevScrollState.scrollXPossible !== scrollState.scrollXPossible) bitmask |= 1 << 9;
663 | if (prevScrollState.trackYVisible !== scrollState.trackYVisible) bitmask |= 1 << 10;
664 | if (prevScrollState.trackXVisible !== scrollState.trackXVisible) bitmask |= 1 << 11;
665 | if (prevScrollState.isRTL !== scrollState.isRTL) bitmask |= 1 << 12;
666 |
667 | if (prevScrollState.contentScrollHeight !== scrollState.contentScrollHeight)
668 | bitmask |= 1 << 13;
669 | if (prevScrollState.contentScrollWidth !== scrollState.contentScrollWidth) bitmask |= 1 << 14;
670 |
671 | if (prevScrollState.zoomLevel !== scrollState.zoomLevel) bitmask |= 1 << 15;
672 |
673 | // if not forced and nothing has changed - skip this update
674 | if (bitmask === 0) {
675 | return prevScrollState;
676 | }
677 | } else {
678 | bitmask = 0b111_1111_1111_1111;
679 | }
680 |
681 | if (!props.native && this.holderElement) {
682 | if (
683 | bitmask & (1 << 13) &&
684 | (props.translateContentSizesToHolder || props.translateContentSizeYToHolder)
685 | ) {
686 | this.holderElement.style.height = `${scrollState.contentScrollHeight}px`;
687 | }
688 |
689 | if (
690 | bitmask & (1 << 14) &&
691 | (props.translateContentSizesToHolder || props.translateContentSizeXToHolder)
692 | ) {
693 | this.holderElement.style.width = `${scrollState.contentScrollWidth}px`;
694 | }
695 |
696 | if (
697 | props.translateContentSizesToHolder ||
698 | props.translateContentSizeYToHolder ||
699 | props.translateContentSizeXToHolder
700 | ) {
701 | if (
702 | (!scrollState.clientHeight && scrollState.contentScrollHeight) ||
703 | (!scrollState.clientWidth && scrollState.contentScrollWidth)
704 | ) {
705 | return;
706 | }
707 | }
708 | }
709 |
710 | // if scrollbars visibility has changed
711 | if (bitmask & (1 << 10) || bitmask & (1 << 11)) {
712 | prevScrollState.scrollYBlocked = scrollState.scrollYBlocked;
713 | prevScrollState.scrollXBlocked = scrollState.scrollXBlocked;
714 | prevScrollState.scrollYPossible = scrollState.scrollYPossible;
715 | prevScrollState.scrollXPossible = scrollState.scrollXPossible;
716 |
717 | if (this.trackYElement && bitmask & (1 << 10)) {
718 | this.trackYElement.style.display = scrollState.trackYVisible ? '' : 'none';
719 | }
720 |
721 | if (this.trackXElement && bitmask & (1 << 11)) {
722 | this.trackXElement.style.display = scrollState.trackXVisible ? '' : 'none';
723 | }
724 |
725 | this.scrollValues = prevScrollState;
726 | this.setState({
727 | trackYVisible: (this.scrollValues.trackYVisible = scrollState.trackYVisible)!,
728 | trackXVisible: (this.scrollValues.trackXVisible = scrollState.trackXVisible)!,
729 | });
730 |
731 | return;
732 | }
733 |
734 | (props.native ? this.updaterNative : this.updaterCustom)(bitmask, scrollState);
735 |
736 | this.scrollValues = scrollState;
737 |
738 | if (!props.native && bitmask & (1 << 15)) {
739 | util.getScrollbarWidth(true);
740 | this.forceUpdate();
741 | }
742 |
743 | this.eventEmitter.emit('update', { ...scrollState }, prevScrollState);
744 |
745 | if (bitmask & (1 << 4) || bitmask & (1 << 5))
746 | this.eventEmitter.emit('scroll', { ...scrollState }, prevScrollState);
747 |
748 | return this.scrollValues;
749 | };
750 |
751 | // eslint-disable-next-line react/sort-comp
752 | public render(): React.ReactNode {
753 | const {
754 | createContext,
755 | rtl,
756 | native,
757 | mobileNative,
758 | momentum,
759 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
760 | noDefaultStyles,
761 |
762 | disableTracksMousewheelScrolling,
763 | disableTrackXMousewheelScrolling,
764 | disableTrackYMousewheelScrolling,
765 |
766 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
767 | disableTracksWidthCompensation,
768 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
769 | disableTrackXWidthCompensation,
770 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
771 | disableTrackYWidthCompensation,
772 |
773 | noScrollX,
774 | noScrollY,
775 | noScroll,
776 |
777 | permanentTrackX,
778 | permanentTrackY,
779 | permanentTracks,
780 |
781 | removeTracksWhenNotUsed,
782 | removeTrackYWhenNotUsed,
783 | removeTrackXWhenNotUsed,
784 |
785 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
786 | minimalThumbSize,
787 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
788 | maximalThumbSize,
789 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
790 | minimalThumbXSize,
791 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
792 | maximalThumbXSize,
793 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
794 | minimalThumbYSize,
795 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
796 | maximalThumbYSize,
797 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
798 | fallbackScrollbarWidth,
799 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
800 | scrollTop,
801 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
802 | scrollLeft,
803 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
804 | trackClickBehavior,
805 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
806 | scrollDetectionThreshold,
807 |
808 | wrapperProps: propsWrapperProps,
809 | scrollerProps: propsScrollerProps,
810 | contentProps: propsContentProps,
811 | trackXProps: propsTrackXProps,
812 | trackYProps: propsTrackYProps,
813 | thumbXProps: propsThumbXProps,
814 | thumbYProps: propsThumbYProps,
815 |
816 | scrollbarWidth: propsScrollbarWidth,
817 |
818 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
819 | elementRef,
820 |
821 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
822 | onUpdate,
823 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
824 | onScroll,
825 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
826 | onScrollStart,
827 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
828 | onScrollStop,
829 |
830 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
831 | translateContentSizesToHolder,
832 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
833 | translateContentSizeYToHolder,
834 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
835 | translateContentSizeXToHolder,
836 |
837 | children,
838 |
839 | ...propsHolderProps
840 | } = this.props as ScrollbarProps;
841 |
842 | const scrollbarWidth = !util.isUndef(propsScrollbarWidth)
843 | ? propsScrollbarWidth
844 | : util.getScrollbarWidth() || 0;
845 |
846 | if (native || (!scrollbarWidth && mobileNative)) {
847 | this.elementRefHolder(null);
848 | this.elementRefWrapper(null);
849 | this.elementRefTrackX(null);
850 | this.elementRefTrackY(null);
851 | this.elementRefThumbX(null);
852 | this.elementRefThumbY(null);
853 |
854 | const contentProps = {
855 | ...propsContentProps,
856 | key: 'ScrollbarsCustom-Content',
857 | className: cnb('ScrollbarsCustom-Content', propsContentProps!.className),
858 | children,
859 | } as ElementPropsWithElementRefAndRenderer;
860 |
861 | const scrollerProps = {
862 | ...propsHolderProps,
863 | className: cnb(
864 | 'ScrollbarsCustom native',
865 | this.state.trackYVisible && 'trackYVisible',
866 | this.state.trackXVisible && 'trackXVisible',
867 | this.state.isRTL && 'rtl',
868 | propsHolderProps.className
869 | ),
870 | style: {
871 | ...propsHolderProps.style,
872 | ...(!util.isUndef(rtl) && {
873 | direction: rtl ? 'rtl' : 'ltr',
874 | }),
875 |
876 | ...(momentum && { WebkitOverflowScrolling: 'touch' }),
877 | overflowX:
878 | noScroll || noScrollX
879 | ? 'hidden'
880 | : permanentTracks || permanentTrackX
881 | ? 'scroll'
882 | : 'auto',
883 | overflowY:
884 | noScroll || noScrollY
885 | ? 'hidden'
886 | : permanentTracks || permanentTrackY
887 | ? 'scroll'
888 | : 'auto',
889 | },
890 | onScroll: this.handleScrollerScroll,
891 | children: renderDivWithRenderer(contentProps, this.elementRefContent),
892 | renderer: propsScrollerProps!.renderer,
893 | elementRef: propsScrollerProps!.elementRef,
894 | } as ElementPropsWithElementRefAndRenderer;
895 |
896 | return renderDivWithRenderer(scrollerProps, this.elementRefScroller);
897 | }
898 |
899 | const styles = Scrollbar.calculateStyles(
900 | this.props,
901 | this.state,
902 | this.scrollValues,
903 | scrollbarWidth
904 | );
905 |
906 | const holderChildren = [] as Array;
907 |
908 | const contentProps = {
909 | ...propsContentProps,
910 | key: 'ScrollbarsCustom-Content',
911 | className: cnb('ScrollbarsCustom-Content', propsContentProps!.className),
912 | style: styles.content,
913 | children: createContext ? (
914 | // eslint-disable-next-line react/jsx-no-constructed-context-values
915 |
916 | {children}
917 |
918 | ) : (
919 | children
920 | ),
921 | } as ElementPropsWithElementRefAndRenderer;
922 |
923 | const scrollerProps = {
924 | ...propsScrollerProps,
925 | key: 'ScrollbarsCustom-Scroller',
926 | className: cnb('ScrollbarsCustom-Scroller', propsScrollerProps!.className),
927 | style: styles.scroller,
928 | children: renderDivWithRenderer(contentProps, this.elementRefContent),
929 | onScroll: this.handleScrollerScroll,
930 | } as ElementPropsWithElementRefAndRenderer;
931 |
932 | const wrapperProps = {
933 | ...propsWrapperProps,
934 | key: 'ScrollbarsCustom-Wrapper',
935 | className: cnb('ScrollbarsCustom-Wrapper', propsWrapperProps!.className),
936 | style: styles.wrapper,
937 | children: renderDivWithRenderer(scrollerProps, this.elementRefScroller),
938 | } as ElementPropsWithElementRefAndRenderer;
939 |
940 | holderChildren.push(renderDivWithRenderer(wrapperProps, this.elementRefWrapper));
941 |
942 | if (this.state.trackYVisible || (!removeTracksWhenNotUsed && !removeTrackYWhenNotUsed)) {
943 | const thumbYProps = {
944 | ...propsThumbYProps,
945 | key: 'ScrollbarsCustom-ThumbY',
946 | style: styles.thumbY,
947 | elementRef: this.elementRefThumbY,
948 | onDrag: this.handleThumbYDrag,
949 | onDragEnd: this.handleThumbYDragEnd,
950 | axis: AXIS_DIRECTION.Y,
951 | } as ScrollbarThumbProps;
952 |
953 | const trackYProps = {
954 | ...propsTrackYProps,
955 | key: 'ScrollbarsCustom-TrackY',
956 | style: styles.trackY,
957 | elementRef: this.elementRefTrackY,
958 | onClick: this.handleTrackYClick,
959 | ...((disableTracksMousewheelScrolling || disableTrackYMousewheelScrolling) && {
960 | onWheel: this.handleTrackYMouseWheel,
961 | }),
962 | axis: AXIS_DIRECTION.Y,
963 | } as ScrollbarTrackProps;
964 |
965 | trackYProps.children = ;
966 | holderChildren.push();
967 | } else {
968 | this.elementRefTrackY(null);
969 | this.elementRefThumbY(null);
970 | }
971 |
972 | if (this.state.trackXVisible || (!removeTracksWhenNotUsed && !removeTrackXWhenNotUsed)) {
973 | const thumbXProps = {
974 | ...propsThumbXProps,
975 | key: 'ScrollbarsCustom-ThumbX',
976 | style: styles.thumbX,
977 | elementRef: this.elementRefThumbX,
978 | onDrag: this.handleThumbXDrag,
979 | onDragEnd: this.handleThumbXDragEnd,
980 | axis: AXIS_DIRECTION.X,
981 | } as ScrollbarThumbProps;
982 |
983 | const trackXProps = {
984 | ...propsTrackXProps,
985 | key: 'ScrollbarsCustom-TrackX',
986 | style: styles.trackX,
987 | elementRef: this.elementRefTrackX,
988 | onClick: this.handleTrackXClick,
989 | ...((disableTracksMousewheelScrolling || disableTrackXMousewheelScrolling) && {
990 | onWheel: this.handleTrackXMouseWheel,
991 | }),
992 | axis: AXIS_DIRECTION.X,
993 | } as ScrollbarTrackProps;
994 |
995 | trackXProps.children = ;
996 | holderChildren.push();
997 | } else {
998 | this.elementRefTrackX(null);
999 | this.elementRefThumbX(null);
1000 | }
1001 |
1002 | const holderProps = {
1003 | ...propsHolderProps,
1004 | className: cnb(
1005 | 'ScrollbarsCustom',
1006 | this.state.trackYVisible && 'trackYVisible',
1007 | this.state.trackXVisible && 'trackXVisible',
1008 | this.state.isRTL && 'rtl',
1009 | propsHolderProps.className
1010 | ),
1011 | style: styles.holder,
1012 | children: holderChildren,
1013 | } as ElementPropsWithElementRefAndRenderer;
1014 |
1015 | return renderDivWithRenderer(holderProps, this.elementRefHolder);
1016 | }
1017 |
1018 | // eslint-disable-next-line class-methods-use-this
1019 | private updaterNative = (): boolean => {
1020 | // just for future
1021 | return true;
1022 | };
1023 |
1024 | private updaterCustom = (bitmask: number, scrollValues: ScrollState): boolean => {
1025 | const { props } = this;
1026 |
1027 | if (this.trackYElement) {
1028 | if (
1029 | this.thumbYElement &&
1030 | (bitmask & Math.trunc(1) ||
1031 | bitmask & (1 << 2) ||
1032 | bitmask & (1 << 4) ||
1033 | bitmask & (1 << 6) ||
1034 | bitmask & (1 << 8))
1035 | ) {
1036 | if (scrollValues.scrollYPossible) {
1037 | const trackInnerSize = util.getInnerHeight(this.trackYElement);
1038 | const thumbSize = util.calcThumbSize(
1039 | scrollValues.scrollHeight,
1040 | scrollValues.clientHeight,
1041 | trackInnerSize,
1042 | props.minimalThumbYSize || props.minimalThumbSize,
1043 | props.maximalThumbYSize || props.maximalThumbSize
1044 | );
1045 | const thumbOffset = util.calcThumbOffset(
1046 | scrollValues.scrollHeight,
1047 | scrollValues.clientHeight,
1048 | trackInnerSize,
1049 | thumbSize,
1050 | scrollValues.scrollTop
1051 | );
1052 |
1053 | this.thumbYElement.style.transform = `translateY(${thumbOffset}px)`;
1054 | this.thumbYElement.style.height = `${thumbSize}px`;
1055 | this.thumbYElement.style.display = '';
1056 | } else {
1057 | this.thumbYElement.style.transform = '';
1058 | this.thumbYElement.style.height = '0px';
1059 | this.thumbYElement.style.display = 'none';
1060 | }
1061 | }
1062 | }
1063 |
1064 | if (this.trackXElement) {
1065 | if (
1066 | this.thumbXElement &&
1067 | (bitmask & (1 << 1) ||
1068 | bitmask & (1 << 3) ||
1069 | bitmask & (1 << 5) ||
1070 | bitmask & (1 << 7) ||
1071 | bitmask & (1 << 9) ||
1072 | bitmask & (1 << 12))
1073 | ) {
1074 | if (scrollValues.scrollXPossible) {
1075 | const trackInnerSize = util.getInnerWidth(this.trackXElement);
1076 | const thumbSize = util.calcThumbSize(
1077 | scrollValues.scrollWidth,
1078 | scrollValues.clientWidth,
1079 | trackInnerSize,
1080 | props.minimalThumbXSize || props.minimalThumbSize,
1081 | props.maximalThumbXSize || props.maximalThumbSize
1082 | );
1083 | let thumbOffset = util.calcThumbOffset(
1084 | scrollValues.scrollWidth,
1085 | scrollValues.clientWidth,
1086 | trackInnerSize,
1087 | thumbSize,
1088 | scrollValues.scrollLeft
1089 | );
1090 |
1091 | if (this.state.isRTL && util.shouldReverseRtlScroll()) {
1092 | thumbOffset += trackInnerSize - thumbSize;
1093 | }
1094 |
1095 | this.thumbXElement.style.transform = `translateX(${thumbOffset}px)`;
1096 | this.thumbXElement.style.width = `${thumbSize}px`;
1097 | this.thumbXElement.style.display = '';
1098 | } else {
1099 | this.thumbXElement.style.transform = '';
1100 | this.thumbXElement.style.width = '0px';
1101 | this.thumbXElement.style.display = 'none';
1102 | }
1103 | }
1104 | }
1105 |
1106 | return true;
1107 | };
1108 |
1109 | private elementRefHolder = (ref: HTMLDivElement | null) => {
1110 | this.holderElement = ref;
1111 | if (util.isFun(this.props.elementRef)) {
1112 | this.props.elementRef(ref);
1113 | }
1114 | };
1115 |
1116 | private elementRefWrapper = (ref: HTMLDivElement | null) => {
1117 | this.wrapperElement = ref;
1118 | if (util.isFun(this.props.wrapperProps!.elementRef)) {
1119 | this.props.wrapperProps!.elementRef(ref);
1120 | }
1121 | };
1122 |
1123 | private elementRefScroller = (ref: HTMLDivElement | null) => {
1124 | this.scrollerElement = ref;
1125 | if (util.isFun(this.props.scrollerProps!.elementRef)) {
1126 | this.props.scrollerProps!.elementRef(ref);
1127 | }
1128 | };
1129 |
1130 | private elementRefContent = (ref: HTMLDivElement | null) => {
1131 | this.contentElement = ref;
1132 | if (util.isFun(this.props.contentProps!.elementRef)) {
1133 | this.props.contentProps!.elementRef(ref);
1134 | }
1135 | };
1136 |
1137 | private elementRefTrackX = (ref: HTMLDivElement | null) => {
1138 | this.trackXElement = ref;
1139 | if (util.isFun(this.props.trackXProps!.elementRef)) {
1140 | this.props.trackXProps!.elementRef(ref);
1141 | }
1142 | };
1143 |
1144 | private elementRefTrackY = (ref: HTMLDivElement | null) => {
1145 | this.trackYElement = ref;
1146 | if (util.isFun(this.props.trackYProps!.elementRef)) {
1147 | this.props.trackYProps!.elementRef(ref);
1148 | }
1149 | };
1150 |
1151 | private elementRefThumbX = (ref: HTMLDivElement | null) => {
1152 | this.thumbXElement = ref;
1153 | if (util.isFun(this.props.thumbXProps!.elementRef)) {
1154 | this.props.thumbXProps!.elementRef(ref);
1155 | }
1156 | };
1157 |
1158 | private elementRefThumbY = (ref: HTMLDivElement | null) => {
1159 | this.thumbYElement = ref;
1160 | if (util.isFun(this.props.thumbYProps!.elementRef)) {
1161 | this.props.thumbYProps!.elementRef(ref);
1162 | }
1163 | };
1164 |
1165 | private handleTrackXClick = (ev: MouseEvent, values: ScrollbarTrackClickParameters): void => {
1166 | if (this.props.trackXProps!.onClick) {
1167 | this.props.trackXProps!.onClick(ev, values);
1168 | }
1169 |
1170 | if (
1171 | !this.scrollerElement ||
1172 | !this.trackXElement ||
1173 | !this.thumbXElement ||
1174 | !this.scrollValues ||
1175 | !this.scrollValues.scrollXPossible
1176 | ) {
1177 | return;
1178 | }
1179 |
1180 | this._scrollDetection();
1181 |
1182 | const thumbSize = this.thumbXElement.clientWidth;
1183 | const trackInnerSize = util.getInnerWidth(this.trackXElement);
1184 | const thumbOffset =
1185 | (this.scrollValues.isRTL && util.shouldReverseRtlScroll()
1186 | ? values.offset + thumbSize / 2 - trackInnerSize
1187 | : values.offset - thumbSize / 2) -
1188 | (Number.parseFloat(getComputedStyle(this.trackXElement).paddingLeft) || 0);
1189 |
1190 | let target = util.calcScrollForThumbOffset(
1191 | this.scrollValues.scrollWidth,
1192 | this.scrollValues.clientWidth,
1193 | trackInnerSize,
1194 | thumbSize,
1195 | thumbOffset
1196 | );
1197 |
1198 | if (this.props.trackClickBehavior === TRACK_CLICK_BEHAVIOR.STEP) {
1199 | target = (
1200 | this.scrollValues.isRTL
1201 | ? this.scrollValues.scrollLeft > target
1202 | : this.scrollValues.scrollLeft < target
1203 | )
1204 | ? this.scrollValues.scrollLeft + this.scrollValues.clientWidth
1205 | : this.scrollValues.scrollLeft - this.scrollValues.clientWidth;
1206 | }
1207 |
1208 | this.scrollerElement.scrollLeft = target;
1209 | };
1210 |
1211 | private handleTrackYClick = (ev: MouseEvent, values: ScrollbarTrackClickParameters): void => {
1212 | if (this.props.trackYProps!.onClick) this.props.trackYProps!.onClick(ev, values);
1213 |
1214 | if (
1215 | !this.scrollerElement ||
1216 | !this.trackYElement ||
1217 | !this.thumbYElement ||
1218 | !this.scrollValues ||
1219 | !this.scrollValues.scrollYPossible
1220 | ) {
1221 | return;
1222 | }
1223 |
1224 | this._scrollDetection();
1225 |
1226 | const thumbSize = this.thumbYElement.clientHeight;
1227 | const target =
1228 | util.calcScrollForThumbOffset(
1229 | this.scrollValues.scrollHeight,
1230 | this.scrollValues.clientHeight,
1231 | util.getInnerHeight(this.trackYElement),
1232 | thumbSize,
1233 | values.offset - thumbSize / 2
1234 | ) - (Number.parseFloat(getComputedStyle(this.trackYElement).paddingTop) || 0);
1235 |
1236 | if (this.props.trackClickBehavior === TRACK_CLICK_BEHAVIOR.JUMP) {
1237 | this.scrollerElement.scrollTop = target;
1238 | } else {
1239 | this.scrollerElement.scrollTop =
1240 | this.scrollValues.scrollTop < target
1241 | ? this.scrollValues.scrollTop + this.scrollValues.clientHeight
1242 | : this.scrollValues.scrollTop - this.scrollValues.clientHeight;
1243 | }
1244 | };
1245 |
1246 | private handleTrackYMouseWheel = (ev: React.WheelEvent) => {
1247 | const { props } = this;
1248 |
1249 | if (props.trackYProps && props.trackYProps.onWheel) {
1250 | props.trackYProps.onWheel(ev);
1251 | }
1252 |
1253 | if (props.disableTracksMousewheelScrolling || props.disableTrackYMousewheelScrolling) {
1254 | return;
1255 | }
1256 |
1257 | this._scrollDetection();
1258 |
1259 | if (!this.scrollerElement || this.scrollValues.scrollYBlocked) {
1260 | return;
1261 | }
1262 |
1263 | this.scrollTop += ev.deltaY;
1264 | };
1265 |
1266 | private handleTrackXMouseWheel = (ev: React.WheelEvent) => {
1267 | const { props } = this;
1268 |
1269 | if (props.trackXProps && props.trackXProps.onWheel) {
1270 | props.trackXProps.onWheel(ev);
1271 | }
1272 |
1273 | if (props.disableTracksMousewheelScrolling || props.disableTrackXMousewheelScrolling) {
1274 | return;
1275 | }
1276 |
1277 | this._scrollDetection();
1278 |
1279 | if (!this.scrollerElement || this.scrollValues.scrollXBlocked) {
1280 | return;
1281 | }
1282 |
1283 | this.scrollLeft += ev.deltaX;
1284 | };
1285 |
1286 | private handleThumbXDrag = (data: DraggableData): void => {
1287 | if (
1288 | !this.trackXElement ||
1289 | !this.thumbXElement ||
1290 | !this.scrollerElement ||
1291 | !this.scrollValues ||
1292 | !this.scrollValues.scrollXPossible
1293 | ) {
1294 | return;
1295 | }
1296 | this._scrollDetection();
1297 |
1298 | const trackRect: ClientRect = this.trackXElement.getBoundingClientRect();
1299 | const styles: CSSStyleDeclaration = getComputedStyle(this.trackXElement);
1300 | const paddingLeft: number = Number.parseFloat(styles.paddingLeft) || 0;
1301 | const paddingRight: number = Number.parseFloat(styles.paddingRight) || 0;
1302 | const trackInnerSize = trackRect.width - paddingLeft - paddingRight;
1303 | const thumbSize = this.thumbXElement.clientWidth;
1304 | const offset =
1305 | this.scrollValues.isRTL && util.shouldReverseRtlScroll()
1306 | ? data.x + thumbSize - trackInnerSize + paddingLeft
1307 | : data.lastX - paddingLeft;
1308 |
1309 | this.scrollerElement.scrollLeft = util.calcScrollForThumbOffset(
1310 | this.scrollValues.scrollWidth,
1311 | this.scrollValues.clientWidth,
1312 | trackInnerSize,
1313 | thumbSize,
1314 | offset
1315 | );
1316 |
1317 | if (this.props.thumbXProps?.onDrag) {
1318 | this.props.thumbXProps.onDrag(data);
1319 | }
1320 | };
1321 |
1322 | private handleThumbXDragEnd = (data: DraggableData): void => {
1323 | this.handleThumbXDrag(data);
1324 |
1325 | if (this.props.thumbXProps?.onDragEnd) {
1326 | this.props.thumbXProps.onDragEnd(data);
1327 | }
1328 | };
1329 |
1330 | private handleThumbYDrag = (data: DraggableData): void => {
1331 | if (
1332 | !this.scrollerElement ||
1333 | !this.trackYElement ||
1334 | !this.thumbYElement ||
1335 | !this.scrollValues ||
1336 | !this.scrollValues.scrollYPossible
1337 | ) {
1338 | return;
1339 | }
1340 | this._scrollDetection();
1341 |
1342 | const trackRect: ClientRect = this.trackYElement.getBoundingClientRect();
1343 | const styles: CSSStyleDeclaration = getComputedStyle(this.trackYElement);
1344 | const paddingTop: number = Number.parseFloat(styles.paddingTop) || 0;
1345 | const paddingBottom: number = Number.parseFloat(styles.paddingBottom) || 0;
1346 | const trackInnerSize = trackRect.height - paddingTop - paddingBottom;
1347 | const thumbSize = this.thumbYElement.clientHeight;
1348 | const offset = data.y - paddingTop;
1349 |
1350 | this.scrollerElement.scrollTop = util.calcScrollForThumbOffset(
1351 | this.scrollValues.scrollHeight,
1352 | this.scrollValues.clientHeight,
1353 | trackInnerSize,
1354 | thumbSize,
1355 | offset
1356 | );
1357 |
1358 | if (this.props.thumbYProps?.onDrag) {
1359 | this.props.thumbYProps.onDrag(data);
1360 | }
1361 | };
1362 |
1363 | private handleThumbYDragEnd = (data: DraggableData): void => {
1364 | this.handleThumbYDrag(data);
1365 |
1366 | if (this.props.thumbYProps?.onDragEnd) {
1367 | this.props.thumbYProps.onDragEnd(data);
1368 | }
1369 | };
1370 |
1371 | private handleScrollerScroll = () => {
1372 | this._scrollDetection();
1373 | };
1374 |
1375 | private _scrollDetection = () => {
1376 | if (!this._scrollDetectionTO) {
1377 | this.eventEmitter.emit('scrollStart', this.getScrollState());
1378 | } else if (isBrowser) {
1379 | window.clearTimeout(this._scrollDetectionTO);
1380 | }
1381 |
1382 | this._scrollDetectionTO = isBrowser
1383 | ? window.setTimeout(this._scrollDetectionCallback, this.props.scrollDetectionThreshold || 0)
1384 | : null;
1385 | };
1386 |
1387 | private _scrollDetectionCallback = () => {
1388 | this._scrollDetectionTO = null;
1389 | this.eventEmitter.emit('scrollStop', this.getScrollState());
1390 | };
1391 | }
1392 |
--------------------------------------------------------------------------------
/src/ScrollbarThumb.tsx:
--------------------------------------------------------------------------------
1 | import { cnb } from 'cnbuilder';
2 | import * as React from 'react';
3 | import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable';
4 | import { AXIS_DIRECTION, ElementPropsWithElementRefAndRenderer } from './types';
5 | import { isBrowser, isFun, isUndef, renderDivWithRenderer } from './util';
6 |
7 | export type DragCallbackData = Pick>;
8 |
9 | export type ScrollbarThumbProps = ElementPropsWithElementRefAndRenderer & {
10 | axis: AXIS_DIRECTION;
11 |
12 | onDrag?: (data: DragCallbackData) => void;
13 | onDragStart?: (data: DragCallbackData) => void;
14 | onDragEnd?: (data: DragCallbackData) => void;
15 |
16 | ref?: (ref: ScrollbarThumb | null) => void;
17 | };
18 |
19 | export default class ScrollbarThumb extends React.Component {
20 | private static selectStartReplacer = () => false;
21 |
22 | public element: HTMLDivElement | null = null;
23 |
24 | public initialOffsetX = 0;
25 |
26 | public initialOffsetY = 0;
27 |
28 | private prevUserSelect: string;
29 |
30 | private prevOnSelectStart: ((ev: Event) => boolean) | null;
31 |
32 | private elementRefHack = React.createRef();
33 |
34 | public lastDragData: DragCallbackData = {
35 | x: 0,
36 | y: 0,
37 | deltaX: 0,
38 | deltaY: 0,
39 | lastX: 0,
40 | lastY: 0,
41 | };
42 |
43 | public componentDidMount(): void {
44 | if (!this.element) {
45 | this.setState(() => {
46 | throw new Error(
47 | " Element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
48 | );
49 | });
50 | }
51 | }
52 |
53 | public componentWillUnmount(): void {
54 | this.handleOnDragStop();
55 |
56 | this.elementRef(null);
57 | }
58 |
59 | public handleOnDragStart = (ev: DraggableEvent, data: DraggableData) => {
60 | if (!this.element) {
61 | this.handleOnDragStop(ev, data);
62 | return;
63 | }
64 |
65 | if (isBrowser) {
66 | this.prevUserSelect = document.body.style.userSelect;
67 | document.body.style.userSelect = 'none';
68 |
69 | this.prevOnSelectStart = document.onselectstart;
70 | document.addEventListener('selectstart', ScrollbarThumb.selectStartReplacer);
71 | }
72 |
73 | if (this.props.onDragStart) {
74 | this.props.onDragStart(
75 | (this.lastDragData = {
76 | x: data.x - this.initialOffsetX,
77 | y: data.y - this.initialOffsetY,
78 | lastX: data.lastX - this.initialOffsetX,
79 | lastY: data.lastY - this.initialOffsetY,
80 | deltaX: data.deltaX,
81 | deltaY: data.deltaY,
82 | })
83 | );
84 | }
85 |
86 | this.element.classList.add('dragging');
87 | };
88 |
89 | public handleOnDrag = (ev: DraggableEvent, data: DraggableData) => {
90 | if (!this.element) {
91 | this.handleOnDragStop(ev, data);
92 | return;
93 | }
94 |
95 | if (this.props.onDrag) {
96 | this.props.onDrag(
97 | (this.lastDragData = {
98 | x: data.x - this.initialOffsetX,
99 | y: data.y - this.initialOffsetY,
100 | lastX: data.lastX - this.initialOffsetX,
101 | lastY: data.lastY - this.initialOffsetY,
102 | deltaX: data.deltaX,
103 | deltaY: data.deltaY,
104 | })
105 | );
106 | }
107 | };
108 |
109 | public handleOnDragStop = (ev?: DraggableEvent, data?: DraggableData) => {
110 | const resultData = data
111 | ? {
112 | x: data.x - this.initialOffsetX,
113 | y: data.y - this.initialOffsetY,
114 | lastX: data.lastX - this.initialOffsetX,
115 | lastY: data.lastY - this.initialOffsetY,
116 | deltaX: data.deltaX,
117 | deltaY: data.deltaY,
118 | }
119 | : this.lastDragData;
120 |
121 | if (this.props.onDragEnd) this.props.onDragEnd(resultData);
122 |
123 | if (this.element) this.element.classList.remove('dragging');
124 |
125 | if (isBrowser) {
126 | document.body.style.userSelect = this.prevUserSelect;
127 |
128 | if (this.prevOnSelectStart) {
129 | document.addEventListener('selectstart', this.prevOnSelectStart);
130 | }
131 |
132 | this.prevOnSelectStart = null;
133 | }
134 |
135 | this.initialOffsetX = 0;
136 | this.initialOffsetY = 0;
137 | this.lastDragData = {
138 | x: 0,
139 | y: 0,
140 | deltaX: 0,
141 | deltaY: 0,
142 | lastX: 0,
143 | lastY: 0,
144 | };
145 | };
146 |
147 | public handleOnMouseDown = (ev: MouseEvent) => {
148 | if (!this.element) {
149 | return;
150 | }
151 |
152 | ev.preventDefault();
153 | ev.stopPropagation();
154 |
155 | if (!isUndef(ev.offsetX)) {
156 | /* istanbul ignore next */
157 | this.initialOffsetX = ev.offsetX;
158 | /* istanbul ignore next */
159 | this.initialOffsetY = ev.offsetY;
160 | } else {
161 | const rect: ClientRect = this.element.getBoundingClientRect();
162 | this.initialOffsetX =
163 | (ev.clientX || (ev as unknown as TouchEvent).touches[0].clientX) - rect.left;
164 | this.initialOffsetY =
165 | (ev.clientY || (ev as unknown as TouchEvent).touches[0].clientY) - rect.top;
166 | }
167 | };
168 |
169 | private elementRef = (ref: HTMLDivElement | null): void => {
170 | if (isFun(this.props.elementRef)) this.props.elementRef(ref);
171 | this.element = ref;
172 |
173 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
174 | // @ts-ignore
175 | this.elementRefHack.current = ref;
176 | };
177 |
178 | public render(): React.ReactElement | null {
179 | const {
180 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
181 | elementRef,
182 |
183 | axis,
184 |
185 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
186 | onDrag,
187 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
188 | onDragEnd,
189 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
190 | onDragStart,
191 |
192 | ...props
193 | } = this.props as ScrollbarThumbProps;
194 |
195 | props.className = cnb(
196 | 'ScrollbarsCustom-Thumb',
197 | axis === AXIS_DIRECTION.X ? 'ScrollbarsCustom-ThumbX' : 'ScrollbarsCustom-ThumbY',
198 | props.className
199 | );
200 |
201 | if (props.renderer) {
202 | (props as ScrollbarThumbProps).axis = axis;
203 | }
204 |
205 | return (
206 |
214 | {renderDivWithRenderer(props, this.elementRef)}
215 |
216 | );
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/src/ScrollbarTrack.tsx:
--------------------------------------------------------------------------------
1 | import { cnb } from 'cnbuilder';
2 | import * as React from 'react';
3 | import { AXIS_DIRECTION, ElementPropsWithElementRefAndRenderer } from './types';
4 | import { isFun, isUndef, renderDivWithRenderer } from './util';
5 |
6 | export interface ScrollbarTrackClickParameters {
7 | axis: AXIS_DIRECTION;
8 | offset: number;
9 | }
10 |
11 | export type ScrollbarTrackProps = ElementPropsWithElementRefAndRenderer & {
12 | axis: AXIS_DIRECTION;
13 |
14 | onClick?: (ev: MouseEvent, values: ScrollbarTrackClickParameters) => void;
15 |
16 | ref?: (ref: ScrollbarTrack | null) => void;
17 | };
18 |
19 | export default class ScrollbarTrack extends React.Component {
20 | public element: HTMLDivElement | null = null;
21 |
22 | public componentDidMount(): void {
23 | if (!this.element) {
24 | this.setState(() => {
25 | throw new Error(
26 | "Element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
27 | );
28 | });
29 | return;
30 | }
31 |
32 | this.element.addEventListener('click', this.handleClick);
33 | }
34 |
35 | public componentWillUnmount(): void {
36 | if (this.element) {
37 | this.element.removeEventListener('click', this.handleClick);
38 | this.element = null;
39 |
40 | this.elementRef(null);
41 | }
42 | }
43 |
44 | private elementRef = (ref: HTMLDivElement | null): void => {
45 | if (isFun(this.props.elementRef)) this.props.elementRef(ref);
46 | this.element = ref;
47 | };
48 |
49 | private handleClick = (ev: MouseEvent) => {
50 | if (!ev || !this.element || ev.button !== 0) {
51 | return;
52 | }
53 |
54 | if (isFun(this.props.onClick) && ev.target === this.element) {
55 | if (!isUndef(ev.offsetX)) {
56 | this.props.onClick(ev, {
57 | axis: this.props.axis,
58 | offset: this.props.axis === AXIS_DIRECTION.X ? ev.offsetX : ev.offsetY,
59 | });
60 | } else {
61 | // support for old browsers
62 | /* istanbul ignore next */
63 | const rect: ClientRect = this.element.getBoundingClientRect();
64 | /* istanbul ignore next */
65 | this.props.onClick(ev, {
66 | axis: this.props.axis,
67 | offset:
68 | this.props.axis === AXIS_DIRECTION.X
69 | ? (ev.clientX || (ev as unknown as TouchEvent).touches[0].clientX) - rect.left
70 | : (ev.clientY || (ev as unknown as TouchEvent).touches[0].clientY) - rect.top,
71 | });
72 | }
73 | }
74 |
75 | return true;
76 | };
77 |
78 | public render(): React.ReactElement | null {
79 | const {
80 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
81 | elementRef,
82 |
83 | axis,
84 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
85 | onClick,
86 |
87 | ...props
88 | } = this.props as ScrollbarTrackProps;
89 |
90 | props.className = cnb(
91 | 'ScrollbarsCustom-Track',
92 | axis === AXIS_DIRECTION.X ? 'ScrollbarsCustom-TrackX' : 'ScrollbarsCustom-TrackY',
93 | props.className
94 | );
95 |
96 | if (props.renderer) {
97 | (props as ScrollbarTrackProps).axis = axis;
98 | }
99 |
100 | return renderDivWithRenderer(props, this.elementRef);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | // eslint-disable-next-line no-restricted-exports
3 | default,
4 | default as Scrollbar,
5 | ScrollbarContext,
6 | ScrollbarProps,
7 | ScrollbarState,
8 | } from './Scrollbar';
9 |
--------------------------------------------------------------------------------
/src/style.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const style = {
4 | holder: {
5 | position: 'relative',
6 | width: '100%',
7 | height: '100%',
8 | } as React.CSSProperties,
9 |
10 | wrapper: {
11 | position: 'absolute',
12 | top: 0,
13 | left: 0,
14 | bottom: 0,
15 | right: 0,
16 | } as React.CSSProperties,
17 |
18 | content: {
19 | boxSizing: 'border-box',
20 | } as React.CSSProperties,
21 |
22 | track: {
23 | common: {
24 | position: 'absolute',
25 | overflow: 'hidden',
26 | borderRadius: 4,
27 | background: 'rgba(0,0,0,.1)',
28 | userSelect: 'none',
29 | } as React.CSSProperties,
30 | x: {
31 | height: 10,
32 | width: 'calc(100% - 20px)',
33 | bottom: 0,
34 | left: 10,
35 | } as React.CSSProperties,
36 | y: {
37 | width: 10,
38 | height: 'calc(100% - 20px)',
39 | top: 10,
40 | } as React.CSSProperties,
41 | },
42 |
43 | thumb: {
44 | common: {
45 | cursor: 'pointer',
46 | borderRadius: 4,
47 | background: 'rgba(0,0,0,.4)',
48 | } as React.CSSProperties,
49 | x: {
50 | height: '100%',
51 | width: 0,
52 | } as React.CSSProperties,
53 | y: {
54 | width: '100%',
55 | height: 0,
56 | } as React.CSSProperties,
57 | },
58 | };
59 |
60 | export default style;
61 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export enum AXIS_DIRECTION {
4 | X = 'x',
5 | Y = 'y',
6 | }
7 |
8 | export enum TRACK_CLICK_BEHAVIOR {
9 | JUMP = 'jump',
10 | STEP = 'step',
11 | }
12 |
13 | export type ElementRef = (element: T | null) => void;
14 |
15 | export type ElementPropsWithElementRef = React.HTMLProps & {
16 | elementRef?: ElementRef;
17 | };
18 |
19 | export type ElementRenderer = React.FC<
20 | React.PropsWithChildren>
21 | >;
22 |
23 | export type ElementPropsWithElementRefAndRenderer = React.HTMLProps & {
24 | elementRef?: ElementRef;
25 | renderer?: ElementRenderer;
26 | };
27 |
28 | /**
29 | * @description Contains all scroll-related values
30 | */
31 | export type ScrollState = {
32 | /**
33 | * @description Scroller's native clientHeight parameter
34 | */
35 | clientHeight: number;
36 | /**
37 | * @description Scroller's native clientWidth parameter
38 | */
39 | clientWidth: number;
40 |
41 | /**
42 | * @description Content's scroll height
43 | */
44 | contentScrollHeight: number;
45 | /**
46 | * @description Content's scroll width
47 | */
48 | contentScrollWidth: number;
49 |
50 | /**
51 | * @description Scroller's native scrollHeight parameter
52 | */
53 | scrollHeight: number;
54 | /**
55 | * @description Scroller's native scrollWidth parameter
56 | */
57 | scrollWidth: number;
58 |
59 | /**
60 | * @description Scroller's native scrollTop parameter
61 | */
62 | scrollTop: number;
63 | /**
64 | * @description Scroller's native scrollLeft parameter
65 | */
66 | scrollLeft: number;
67 |
68 | /**
69 | * @description Indicates whether vertical scroll blocked via properties
70 | */
71 | scrollYBlocked: boolean;
72 | /**
73 | * @description Indicates whether horizontal scroll blocked via properties
74 | */
75 | scrollXBlocked: boolean;
76 |
77 | /**
78 | * @description Indicates whether the content overflows vertically and scrolling not blocked
79 | */
80 | scrollYPossible: boolean;
81 | /**
82 | * @description Indicates whether the content overflows horizontally and scrolling not blocked
83 | */
84 | scrollXPossible: boolean;
85 |
86 | /**
87 | * @description Indicates whether vertical track is visible
88 | */
89 | trackYVisible: boolean;
90 | /**
91 | * @description Indicates whether horizontal track is visible
92 | */
93 | trackXVisible: boolean;
94 |
95 | /**
96 | * @description Indicates whether display direction is right-to-left
97 | */
98 | isRTL?: boolean;
99 |
100 | /**
101 | * @description Pages zoom level - it affects scrollbars
102 | */
103 | zoomLevel: number;
104 | };
105 |
--------------------------------------------------------------------------------
/src/util.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ElementPropsWithElementRefAndRenderer, ElementRef } from './types';
3 |
4 | let doc: Document | null = typeof document === 'object' ? document : null;
5 |
6 | export const isBrowser =
7 | typeof window !== 'undefined' &&
8 | typeof navigator !== 'undefined' &&
9 | typeof document !== 'undefined';
10 |
11 | export const isUndef = (v: any): v is Exclude => {
12 | return typeof v === 'undefined';
13 | };
14 |
15 | export const isFun = (v: any): v is CallableFunction => {
16 | return typeof v === 'function';
17 | };
18 |
19 | export const isNum = (v: any): v is number => {
20 | return typeof v === 'number';
21 | };
22 |
23 | /**
24 | * @description Will return renderer result if presented, div element otherwise.
25 | * If renderer is presented it'll receive `elementRef` function which should be used as HTMLElement's ref.
26 | *
27 | * @param props {ElementPropsWithElementRefAndRenderer}
28 | * @param elementRef {ElementRef}
29 | */
30 | export const renderDivWithRenderer = (
31 | props: ElementPropsWithElementRefAndRenderer,
32 | elementRef: ElementRef
33 | ): React.ReactElement | null => {
34 | if (isFun(props.renderer)) {
35 | props.elementRef = elementRef;
36 |
37 | const { renderer } = props;
38 |
39 | delete props.renderer;
40 |
41 | return renderer(props);
42 | }
43 |
44 | delete props.elementRef;
45 |
46 | return ;
47 | };
48 |
49 | const getInnerSize = (
50 | el: HTMLElement,
51 | dimension: string,
52 | padding1: string,
53 | padding2: string
54 | ): number => {
55 | const styles = getComputedStyle(el);
56 |
57 | if (styles.boxSizing === 'border-box') {
58 | return Math.max(
59 | 0,
60 | (Number.parseFloat(styles[dimension] as string) || 0) -
61 | (Number.parseFloat(styles[padding1] as string) || 0) -
62 | (Number.parseFloat(styles[padding2] as string) || 0)
63 | );
64 | }
65 |
66 | return Number.parseFloat(styles[dimension] as string) || 0;
67 | };
68 |
69 | /**
70 | * @description Return element's height without padding
71 | */
72 | export const getInnerHeight = (el: HTMLElement): number => {
73 | return getInnerSize(el, 'height', 'paddingTop', 'paddingBottom');
74 | };
75 |
76 | /**
77 | * @description Return element's width without padding
78 | */
79 | export const getInnerWidth = (el: HTMLElement): number => {
80 | return getInnerSize(el, 'width', 'paddingLeft', 'paddingRight');
81 | };
82 |
83 | /**
84 | * @description Return unique UUID v4
85 | */
86 | export const uuid = () => {
87 | // eslint-disable-next-line @typescript-eslint/no-shadow
88 | let uuid = '';
89 |
90 | for (let i = 0; i < 32; i++) {
91 | switch (i) {
92 | case 8:
93 | case 20: {
94 | uuid += `-${Math.trunc(Math.random() * 16).toString(16)}`;
95 |
96 | break;
97 | }
98 | case 12: {
99 | uuid += '-4';
100 |
101 | break;
102 | }
103 | case 16: {
104 | uuid += `-${((Math.random() * 16) | (0 & 3) | 8).toString(16)}`;
105 |
106 | break;
107 | }
108 | default: {
109 | uuid += Math.trunc(Math.random() * 16).toString(16);
110 | }
111 | }
112 | }
113 |
114 | return uuid;
115 | };
116 |
117 | /**
118 | * @description Calculate thumb size for given viewport and track parameters
119 | *
120 | * @param {number} contentSize - Scrollable content size
121 | * @param {number} viewportSize - Viewport size
122 | * @param {number} trackSize - Track size thumb can move
123 | * @param {number} minimalSize - Minimal thumb's size
124 | * @param {number} maximalSize - Maximal thumb's size
125 | */
126 | export const calcThumbSize = (
127 | contentSize: number,
128 | viewportSize: number,
129 | trackSize: number,
130 | minimalSize?: number,
131 | maximalSize?: number
132 | ): number => {
133 | if (viewportSize >= contentSize) {
134 | return 0;
135 | }
136 |
137 | let thumbSize = (viewportSize / contentSize) * trackSize;
138 |
139 | if (isNum(maximalSize)) {
140 | thumbSize = Math.min(maximalSize, thumbSize);
141 | }
142 | if (isNum(minimalSize)) {
143 | thumbSize = Math.max(minimalSize, thumbSize);
144 | }
145 |
146 | return thumbSize;
147 | };
148 |
149 | /**
150 | * @description Calculate thumb offset for given viewport, track and thumb parameters
151 | *
152 | * @param {number} contentSize - Scrollable content size
153 | * @param {number} viewportSize - Viewport size
154 | * @param {number} trackSize - Track size thumb can move
155 | * @param {number} thumbSize - Thumb size
156 | * @param {number} scroll - Scroll value to represent
157 | */
158 | export const calcThumbOffset = (
159 | contentSize: number,
160 | viewportSize: number,
161 | trackSize: number,
162 | thumbSize: number,
163 | scroll: number
164 | ): number => {
165 | if (!scroll || !thumbSize || viewportSize >= contentSize) {
166 | return 0;
167 | }
168 |
169 | return ((trackSize - thumbSize) * scroll) / (contentSize - viewportSize);
170 | };
171 |
172 | /**
173 | * @description Calculate scroll for given viewport, track and thumb parameters
174 | *
175 | * @param {number} contentSize - Scrollable content size
176 | * @param {number} viewportSize - Viewport size
177 | * @param {number} trackSize - Track size thumb can move
178 | * @param {number} thumbSize - Thumb size
179 | * @param {number} thumbOffset - Thumb's offset representing the scroll
180 | */
181 | export const calcScrollForThumbOffset = (
182 | contentSize: number,
183 | viewportSize: number,
184 | trackSize: number,
185 | thumbSize: number,
186 | thumbOffset: number
187 | ): number => {
188 | if (!thumbOffset || !thumbSize || viewportSize >= contentSize) {
189 | return 0;
190 | }
191 |
192 | return (thumbOffset * (contentSize - viewportSize)) / (trackSize - thumbSize);
193 | };
194 |
195 | /**
196 | * @description Set the document node to calculate the scrollbar width.
197 | * null will force getter to return 0 (it'll imitate SSR).
198 | */
199 | export const _dbgSetDocument = (v: Document | null): Document | null => {
200 | if (v === null || v instanceof HTMLDocument) {
201 | doc = v;
202 | return doc;
203 | }
204 |
205 | throw new TypeError(
206 | `override value expected to be an instance of HTMLDocument or null, got ${typeof v}`
207 | );
208 | };
209 |
210 | /**
211 | * @description Return current document node
212 | */
213 | export const _dbgGetDocument = (): Document | null => doc;
214 |
215 | interface GetScrollbarWidthFN {
216 | _cache?: number;
217 |
218 | (force?: boolean): number | undefined;
219 | }
220 |
221 | /**
222 | * @description Returns scrollbar width specific for current environment. Can return undefined if DOM is not ready yet.
223 | */
224 | export const getScrollbarWidth: GetScrollbarWidthFN = (force = false): number | undefined => {
225 | if (!doc) {
226 | getScrollbarWidth._cache = 0;
227 |
228 | return getScrollbarWidth._cache;
229 | }
230 |
231 | if (!force && !isUndef(getScrollbarWidth._cache)) {
232 | return getScrollbarWidth._cache as number;
233 | }
234 |
235 | const el = doc.createElement('div');
236 | el.setAttribute(
237 | 'style',
238 | 'position:absolute;width:100px;height:100px;top:-999px;left:-999px;overflow:scroll;'
239 | );
240 |
241 | doc.body.append(el);
242 |
243 | /* istanbul ignore next */
244 | if (el.clientWidth === 0) {
245 | // Do not even cache this value because there is no calculations. Issue https://github.com/xobotyi/react-scrollbars-custom/issues/123
246 | el.remove();
247 | return;
248 | }
249 | getScrollbarWidth._cache = 100 - el.clientWidth;
250 | el.remove();
251 |
252 | return getScrollbarWidth._cache;
253 | };
254 |
255 | interface ShouldReverseRtlScroll {
256 | _cache?: boolean;
257 |
258 | (force?: boolean): boolean;
259 | }
260 |
261 | /**
262 | * @description Detect need of horizontal scroll reverse while RTL.
263 | */
264 | export const shouldReverseRtlScroll: ShouldReverseRtlScroll = (force = false): boolean => {
265 | if (!force && !isUndef(shouldReverseRtlScroll._cache)) {
266 | return shouldReverseRtlScroll._cache as boolean;
267 | }
268 |
269 | if (!doc) {
270 | shouldReverseRtlScroll._cache = false;
271 |
272 | return shouldReverseRtlScroll._cache;
273 | }
274 |
275 | const el = doc.createElement('div');
276 | const child = doc.createElement('div');
277 |
278 | el.append(child);
279 |
280 | el.setAttribute(
281 | 'style',
282 | 'position:absolute;width:100px;height:100px;top:-999px;left:-999px;overflow:scroll;direction:rtl'
283 | );
284 | child.setAttribute('style', 'width:1000px;height:1000px');
285 |
286 | doc.body.append(el);
287 |
288 | el.scrollLeft = -50;
289 | shouldReverseRtlScroll._cache = el.scrollLeft === -50;
290 |
291 | el.remove();
292 |
293 | return shouldReverseRtlScroll._cache;
294 | };
295 |
--------------------------------------------------------------------------------
/testbench/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | react-scrollbars-custom
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/testbench/app/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDom from 'react-dom';
3 | import { Scrollbar } from '../../src';
4 |
5 | export const PARAGRAPHS_TEXT = [
6 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus aliquet malesuada efficitur. Praesent semper tortor id egestas volutpat. Aenean dui sapien, fermentum et dictum sagittis, finibus eget velit. Maecenas sed finibus risus, sed hendrerit odio. Nullam volutpat metus non enim consequat auctor. Vivamus gravida nibh in tempus vehicula. Donec venenatis luctus nulla, id facilisis turpis pharetra aliquet. Praesent non orci in turpis dapibus rutrum. Donec venenatis fermentum velit sit amet egestas. Duis quis ipsum et arcu scelerisque sagittis. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean suscipit feugiat justo a luctus. Sed placerat sapien sit amet risus efficitur, sed pharetra tellus faucibus.',
7 | 'Nam mollis lectus ac mollis aliquet. Pellentesque sed dolor ac leo porttitor maximus nec sed velit. Maecenas quis faucibus nisl. Cras vel dignissim arcu. Donec in velit condimentum, efficitur velit nec, dignissim odio. Sed sapien tortor, facilisis in lacus id, condimentum lobortis nisl. Vestibulum porta sollicitudin risus, at tincidunt felis. Pellentesque pulvinar ante enim, vitae sagittis est vestibulum accumsan. Phasellus lobortis massa ac metus dictum interdum. Praesent ut eros malesuada, euismod diam eu, luctus orci. Sed ornare metus mauris, at ornare lectus malesuada eu. Fusce metus dui, sodales non metus in, rutrum pulvinar ante. Pellentesque a dolor massa. In auctor pellentesque eros. Curabitur pellentesque in mi non scelerisque.',
8 | 'Nunc a luctus tortor. Duis maximus urna quis est commodo sodales. Sed sit amet accumsan purus, eget pellentesque est. In cursus metus ipsum, in condimentum ante molestie ac. Donec sit amet pulvinar turpis. Suspendisse elementum, ex sed lobortis fermentum, urna ex efficitur turpis, at consequat leo tellus in augue. Sed et varius ante. Phasellus iaculis, diam id ullamcorper semper, mi ipsum interdum ipsum, non fermentum est ligula sed mi. Vestibulum ornare interdum nulla, a convallis dolor molestie non. Curabitur convallis scelerisque augue quis fringilla. Aenean gravida libero nec eros convallis, vel lobortis sapien bibendum. Quisque faucibus lacus id purus placerat dapibus. Praesent pretium, tellus a dictum suscipit, ex leo scelerisque augue, vitae semper libero erat vel massa.',
9 | 'Ut vitae condimentum nunc. Suspendisse nec magna pulvinar, molestie massa vitae, convallis odio. Vestibulum imperdiet metus velit, vitae interdum libero viverra non. Maecenas commodo, nulla sit amet vehicula blandit, eros ante consectetur turpis, vel viverra mi felis a tellus. Pellentesque consectetur purus massa, sit amet commodo neque rutrum eget. Aenean dictum magna nec condimentum dignissim. Phasellus enim mauris, maximus quis auctor et, congue sit amet justo. Nullam vel lacus non nisi bibendum euismod. Integer mattis at mi vel sollicitudin. Sed a turpis orci. Etiam est urna, dictum vitae ullamcorper sed, sollicitudin at magna. Sed vel iaculis augue. Fusce aliquet dictum urna, eget rutrum ante aliquet eu. In ac est ut tellus sagittis pretium. Pellentesque venenatis nulla et risus maximus, in hendrerit sapien rhoncus. Nunc ut tempor massa.',
10 | 'Aenean tincidunt porttitor leo id sodales. Integer feugiat leo rutrum mi euismod convallis. In auctor arcu eget ligula cursus, vel gravida neque posuere. Donec ipsum enim, vulputate pulvinar sagittis at, varius in nibh. Quisque quis leo non metus lobortis euismod. Aenean et ultrices enim. Mauris posuere turpis eget imperdiet vestibulum. Morbi imperdiet mi felis, at varius magna pharetra vitae. Vestibulum at venenatis neque.',
11 | 'Nulla nulla odio, ullamcorper nec lectus id, eleifend feugiat lorem. Praesent vulputate lacus nisi, a eleifend ex mollis eget. Fusce tempus convallis finibus. In sed sapien non mauris pellentesque semper at vel ante. Maecenas ut libero a tortor mollis pretium. Mauris laoreet nulla risus, non fermentum sapien fringilla pulvinar. Ut et mauris rhoncus, viverra orci ut, vulputate dui. Praesent volutpat, enim vel congue egestas, purus tortor pulvinar lorem, eget venenatis ex lorem vitae neque. Donec rutrum nulla quis odio fringilla, ac fringilla velit iaculis. Curabitur sit amet ante ut mi sagittis luctus sit amet pretium leo. Proin nec malesuada velit. Ut dictum orci at sapien suscipit, at mattis tortor vehicula. Nulla dui magna, venenatis nec imperdiet in, venenatis quis leo. Proin vel pulvinar arcu. In non ullamcorper tellus. Etiam dapibus, nibh nec efficitur blandit, erat lacus placerat dui, id vehicula quam ex nec eros.',
12 | 'Nulla posuere condimentum scelerisque. Nulla rutrum posuere mi sed cursus. Aliquam laoreet faucibus nunc, in luctus orci facilisis ac. Sed aliquet, sem id lobortis congue, mauris ante consectetur diam, sit amet sagittis dui quam sit amet nibh. In vitae leo aliquet, fringilla nulla at, maximus urna. Phasellus elementum eros vel finibus dignissim. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla lectus sapien, scelerisque ac libero vel, fermentum sagittis nulla. Sed ac scelerisque magna.',
13 | 'Ut interdum, dolor in maximus ornare, tortor erat eleifend sapien, vel bibendum est quam sit amet justo. Quisque urna odio, fringilla in tristique ut, faucibus sit amet arcu. Nulla tempus lectus sem, nec scelerisque leo porttitor volutpat. Fusce tristique efficitur suscipit. Fusce gravida lectus vitae molestie fringilla. Morbi mi eros, auctor vel lorem id, ornare viverra ante. Sed pharetra interdum maximus.',
14 | 'Nam sodales sem ut turpis porttitor, sed condimentum tortor iaculis. Quisque consectetur facilisis dui. In eu turpis euismod, lobortis mauris non, rhoncus risus. Aliquam erat volutpat. Nam fermentum interdum purus in rutrum. Duis tortor sapien, feugiat sit amet libero in, ornare suscipit neque. Vivamus sed sodales lorem. Donec quis mauris at elit tempor rhoncus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nunc sit amet commodo magna, ac sollicitudin lectus. In accumsan arcu diam, in euismod elit molestie eget. Aenean dui metus, sagittis a ex vitae, pretium pharetra mauris.',
15 | 'Aliquam libero odio, feugiat in mattis eu, sagittis a eros. Vivamus quis lacus lectus. Vestibulum mi diam, pulvinar id neque et, porttitor aliquam magna. Nullam finibus ex ex, quis volutpat odio scelerisque sed. Phasellus in felis semper, cursus erat eu, interdum lorem. Quisque finibus quis elit eu semper. Etiam at erat eleifend, mattis nisl in, molestie enim. Aliquam nec tincidunt mauris.',
16 | 'Morbi vel purus pretium, ullamcorper elit id, porttitor justo. Donec ut varius lacus, ut aliquam turpis. Pellentesque condimentum nec orci ut suscipit. Cras at mi sodales, tincidunt risus eget, tristique magna. Proin auctor sollicitudin arcu vitae convallis. Morbi aliquet sapien ut velit vehicula convallis. Vivamus mi metus, finibus vel sollicitudin quis, pulvinar nec nunc. Vestibulum ornare justo vel ex condimentum dignissim ac nec dolor. Pellentesque et diam et leo cursus interdum. Integer tincidunt massa justo, a efficitur libero vehicula id. Cras ex lacus, viverra ut orci nec, ultrices porta ipsum. Integer in placerat magna. Duis semper tempus dui, ac rutrum diam fringilla eu. Suspendisse purus massa, bibendum eu aliquet in, convallis sed augue. Proin finibus auctor elit, in pharetra leo interdum a.',
17 | 'Vestibulum risus risus, tempus nec aliquet sit amet, gravida sed ipsum. In hac habitasse platea dictumst. In tincidunt nec mi id feugiat. Pellentesque luctus libero eget quam efficitur, at cursus purus cursus. In at dolor velit. Duis tristique urna eget blandit porttitor. In neque dolor, pharetra in risus id, egestas imperdiet leo. Duis nisl dolor, vestibulum eget fringilla non, vestibulum quis nisl. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Curabitur cursus arcu sed rutrum faucibus. Maecenas vel egestas nisi. Vivamus gravida lectus sed imperdiet interdum. Integer vestibulum eros eget justo pharetra, eget lobortis velit ultrices. Curabitur sagittis non metus eget porta. Morbi nec ligula et massa cursus posuere a quis ligula. In hac habitasse platea dictumst.Nulla sit amet orci at nunc efficitur tempus in nec enim. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Nunc viverra, arcu a malesuada molestie, sapien lacus rhoncus mi, sit amet posuere nisl eros eget turpis. Donec rutrum diam sapien, quis luctus nisi facilisis non. Aliquam a tellus id dolor congue lacinia. Aenean eu lorem nec libero accumsan feugiat quis id erat. Pellentesque condimentum ut purus egestas varius. Nullam non consequat enim, quis dapibus sem. Curabitur hendrerit non magna in pellentesque. Nam ac malesuada lacus. Mauris vulputate, mauris non lacinia volutpat, ipsum nisl pellentesque tellus, sollicitudin feugiat dui lorem eget justo. Pellentesque ornare nulla sed arcu ultricies, eu pulvinar dolor posuere. Cras ante odio, luctus sed tellus nec, maximus fringilla erat. Fusce auctor turpis a nibh egestas, vel iaculis neque aliquet.',
18 | ];
19 |
20 | export function getRandomParagraphText() {
21 | return PARAGRAPHS_TEXT[Math.floor(Math.random() * PARAGRAPHS_TEXT.length)];
22 | }
23 |
24 | export function renderAmountOfParagraphs(
25 | amount = 5,
26 | paragraphsProps: React.HTMLProps = {}
27 | ) {
28 | const result: Array = [];
29 |
30 | for (; amount--; ) {
31 | result.push(
32 |
33 | {getRandomParagraphText()}
34 |
35 | );
36 | }
37 |
38 | return result;
39 | }
40 |
41 | ReactDom.render(
42 |
43 |
44 | {renderAmountOfParagraphs(10, { style: { width: '150%' } })}
45 |
46 | ,
47 | document.querySelector('#AppRoot')
48 | );
49 |
--------------------------------------------------------------------------------
/testbench/benchmarks.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Some critical benchmarks
6 |
7 |
8 |
9 |
10 |
50 |
51 |
52 |
53 | run inner sizes benchmark
56 | run uuid benchmark
57 |
58 |
59 |
60 |
387 |
388 |
--------------------------------------------------------------------------------
/testbench/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "devDependencies": {
4 | "@babel/core": "^7.9.6",
5 | "@babel/plugin-proposal-class-properties": "^7.8.3",
6 | "@babel/plugin-proposal-object-rest-spread": "^7.9.6",
7 | "@babel/plugin-transform-runtime": "^7.9.6",
8 | "@babel/preset-env": "^7.9.6",
9 | "@babel/preset-react": "^7.9.4",
10 | "@babel/preset-typescript": "^7.9.0",
11 | "@types/react": "^16.9.35",
12 | "@types/react-dom": "^16.9.8",
13 | "babel-loader": "^8.1.0",
14 | "css-loader": "^3.5.3",
15 | "html-loader": "^1.1.0",
16 | "html-webpack-plugin": "^4.3.0",
17 | "mini-css-extract-plugin": "^0.9.0",
18 | "prop-types": "^15.7.2",
19 | "react": "^16.13.1",
20 | "react-dom": "^16.13.1",
21 | "sass-loader": "^8.0.2",
22 | "ts-loader": "^7.0.4",
23 | "typescript": "^3.9.3",
24 | "webpack": "^4.43.0",
25 | "webpack-cli": "^3.3.11",
26 | "webpack-dev-server": "^3.11.0"
27 | },
28 | "scripts": {
29 | "devserver": "webpack-dev-server --hot --colors --progress"
30 | },
31 | "dependencies": {
32 | "@babel/runtime": "^7.5.5",
33 | "@babel/runtime-corejs3": "^7.5.5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/testbench/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('node:path');
2 | // eslint-disable-next-line import/no-unresolved
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | // eslint-disable-next-line import/no-unresolved
5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
6 |
7 | const dist = path.join(__dirname, 'dist');
8 |
9 | module.exports = {
10 | mode: 'development',
11 | devtool: 'source-map',
12 | target: 'web',
13 | entry: path.join(__dirname, 'app/index.tsx'),
14 | output: {
15 | filename: 'bundle.js',
16 | },
17 | resolve: {
18 | extensions: ['.ts', '.tsx', '.js'],
19 | },
20 | optimization: {
21 | minimize: false,
22 | noEmitOnErrors: true,
23 | nodeEnv: 'development',
24 | },
25 | devServer: {
26 | contentBase: dist,
27 | port: 3000,
28 | compress: false,
29 | progress: true,
30 | },
31 | plugins: [
32 | new HtmlWebpackPlugin({
33 | template: path.join(__dirname, '/app/index.html'),
34 | }),
35 | new MiniCssExtractPlugin({
36 | filename: '[name].css',
37 | chunkFilename: '[id].css',
38 | }),
39 | ],
40 | module: {
41 | rules: [
42 | {
43 | test: /\.html$/,
44 | use: 'html-loader',
45 | },
46 | {
47 | test: /\.scss$/,
48 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
49 | },
50 | {
51 | test: /\.tsx?$/,
52 | loader: 'ts-loader',
53 | },
54 | {
55 | test: /\.jsx?$/,
56 | exclude: /node_modules/,
57 | use: {
58 | loader: 'babel-loader',
59 | options: {
60 | comments: true,
61 | cacheDirectory: false,
62 | presets: [
63 | [
64 | '@babel/preset-env',
65 | {
66 | targets: {
67 | browsers: [
68 | 'Chrome >= 52',
69 | 'FireFox >= 44',
70 | 'Safari >= 7',
71 | 'Explorer 11',
72 | 'last 4 Edge versions',
73 | ],
74 | },
75 | },
76 | ],
77 | '@babel/preset-react',
78 | ],
79 | plugins: [
80 | '@babel/plugin-proposal-class-properties',
81 | '@babel/plugin-proposal-object-rest-spread',
82 | ],
83 | },
84 | },
85 | },
86 | ],
87 | },
88 | };
89 |
--------------------------------------------------------------------------------
/tests/Emittr.spec.ts:
--------------------------------------------------------------------------------
1 | import Emittr from '../src/Emittr';
2 |
3 | describe('Emittr', () => {
4 | it('should create instance with given max handlers count', () => {
5 | const instance = new Emittr(25);
6 | expect(instance.getMaxHandlers()).toBe(25);
7 | });
8 |
9 | describe('getMaxHandlers', () => {
10 | it('should return a number', () => {
11 | const instance = new Emittr();
12 | expect(typeof instance.getMaxHandlers()).toBe('number');
13 | });
14 | });
15 |
16 | describe('setMaxHandlers', () => {
17 | it('should return an instance', () => {
18 | const instance = new Emittr();
19 | expect(instance.setMaxHandlers(25)).toBe(instance);
20 | });
21 |
22 | it('should set the max handlers number', () => {
23 | const instance = new Emittr();
24 | instance.setMaxHandlers(25);
25 | expect(instance.getMaxHandlers()).toBe(25);
26 | instance.setMaxHandlers(5);
27 | expect(instance.getMaxHandlers()).toBe(5);
28 | });
29 |
30 | it('should throw if input number <= 0', () => {
31 | const instance = new Emittr();
32 |
33 | let err: any = null;
34 |
35 | try {
36 | instance.setMaxHandlers(0);
37 | } catch (error) {
38 | err = error;
39 | }
40 | expect(err instanceof TypeError).toBeTruthy();
41 | expect(instance.getMaxHandlers()).toBe(10);
42 |
43 | try {
44 | instance.setMaxHandlers(-5);
45 | } catch (error) {
46 | err = error;
47 | }
48 | expect(err instanceof TypeError).toBeTruthy();
49 | expect(instance.getMaxHandlers()).toBe(10);
50 | });
51 | });
52 |
53 | describe('on', () => {
54 | it('should add event handler to the list', () => {
55 | const instance = new Emittr();
56 | instance.on('test', () => {});
57 |
58 | // @ts-expect-error for testing purposes
59 | expect(Array.isArray(instance._handlers.test)).toBeTruthy();
60 | // @ts-expect-error for testing purposes
61 | expect(instance._handlers.test.length).toBe(1);
62 | });
63 |
64 | it('should return emitter instance', () => {
65 | const instance = new Emittr();
66 | const handler1 = jasmine.createSpy();
67 | expect(instance.on('test', handler1)).toBe(instance);
68 | });
69 |
70 | it('should add handlers to the end', () => {
71 | const instance = new Emittr();
72 | const handler1 = jasmine.createSpy();
73 | const handler2 = jasmine.createSpy();
74 | instance.on('test', handler1);
75 | instance.on('test', handler2);
76 |
77 | // @ts-expect-error for testing purposes
78 | expect(instance._handlers.test[0]).toBe(handler1);
79 | // @ts-expect-error for testing purposes
80 | expect(instance._handlers.test[1]).toBe(handler2);
81 | });
82 |
83 | it('should emit addHandler event', () => {
84 | const instance = new Emittr();
85 | const addHandlerTriggered = jasmine.createSpy();
86 | instance.on('addHandler', addHandlerTriggered);
87 | instance.on('test', () => {});
88 |
89 | expect(addHandlerTriggered).toHaveBeenCalledTimes(1);
90 | });
91 |
92 | it('adding addHandler handler should not emit itself', () => {
93 | const instance = new Emittr();
94 | const addHandlerTriggered = jasmine.createSpy();
95 | instance.on('addHandler', addHandlerTriggered);
96 |
97 | expect(addHandlerTriggered).not.toHaveBeenCalled();
98 | });
99 |
100 | it('should throw if adding non-function', () => {
101 | const instance = new Emittr();
102 |
103 | let err: any = null;
104 |
105 | try {
106 | // @ts-expect-error for testing purposes
107 | instance.on('test', {});
108 | } catch (error) {
109 | err = error;
110 | }
111 | expect(err instanceof TypeError).toBeTruthy();
112 | });
113 | });
114 |
115 | describe('prependOn', () => {
116 | it('should add event handler to the list', () => {
117 | const instance = new Emittr();
118 | instance.prependOn('test', () => {});
119 |
120 | // @ts-expect-error for testing purposes
121 | expect(Array.isArray(instance._handlers.test)).toBeTruthy();
122 | // @ts-expect-error for testing purposes
123 | expect(instance._handlers.test.length).toBe(1);
124 | });
125 |
126 | it('should return emitter instance', () => {
127 | const instance = new Emittr();
128 | const handler1 = jasmine.createSpy();
129 | expect(instance.prependOn('test', handler1)).toBe(instance);
130 | });
131 |
132 | it('should add handlers to the start', () => {
133 | const instance = new Emittr();
134 | const handler1 = jasmine.createSpy();
135 | const handler2 = jasmine.createSpy();
136 | instance.prependOn('test', handler1);
137 | instance.prependOn('test', handler2);
138 |
139 | // @ts-expect-error for testing purposes
140 | expect(instance._handlers.test[1]).toBe(handler1);
141 | // @ts-expect-error for testing purposes
142 | expect(instance._handlers.test[0]).toBe(handler2);
143 | });
144 |
145 | it('should emit addHandler event', () => {
146 | const instance = new Emittr();
147 | const addHandlerTriggered = jasmine.createSpy();
148 | instance.prependOn('addHandler', addHandlerTriggered);
149 | instance.prependOn('test', () => {});
150 |
151 | expect(addHandlerTriggered).toHaveBeenCalledTimes(1);
152 | });
153 |
154 | it('adding addHandler handler should not emit itself', () => {
155 | const instance = new Emittr();
156 | const addHandlerTriggered = jasmine.createSpy();
157 | instance.prependOn('addHandler', addHandlerTriggered);
158 |
159 | expect(addHandlerTriggered).not.toHaveBeenCalled();
160 | });
161 |
162 | it('should throw if adding non-function', () => {
163 | const instance = new Emittr();
164 |
165 | let err: any = null;
166 |
167 | try {
168 | // @ts-expect-error for testing purposes
169 | instance.prependOn('test', {});
170 | } catch (error) {
171 | err = error;
172 | }
173 | expect(err instanceof TypeError).toBeTruthy();
174 | });
175 | });
176 |
177 | describe('once', () => {
178 | it('should add event handler to the list', () => {
179 | const instance = new Emittr();
180 | const handler1 = jasmine.createSpy();
181 | instance.once('test', handler1);
182 |
183 | // @ts-expect-error for testing purposes
184 | expect(Array.isArray(instance._handlers.test)).toBeTruthy();
185 | // @ts-expect-error for testing purposes
186 | expect(instance._handlers.test.length).toBe(1);
187 | // @ts-expect-error for testing purposes
188 | expect(instance._handlers.test[0].handler).toBe(handler1);
189 | });
190 |
191 | it('should return emitter instance', () => {
192 | const instance = new Emittr();
193 | const handler1 = jasmine.createSpy();
194 | expect(instance.once('test', handler1)).toBe(instance);
195 | });
196 |
197 | it('should add handlers to the end', () => {
198 | const instance = new Emittr();
199 | const handler1 = jasmine.createSpy();
200 | const handler2 = jasmine.createSpy();
201 | instance.once('test', handler1);
202 | instance.once('test', handler2);
203 |
204 | // @ts-expect-error for testing purposes
205 | expect(instance._handlers.test[0].handler).toBe(handler1);
206 | // @ts-expect-error for testing purposes
207 | expect(instance._handlers.test[1].handler).toBe(handler2);
208 | });
209 |
210 | it('should emit addHandler event', () => {
211 | const instance = new Emittr();
212 | const addHandlerTriggered = jasmine.createSpy();
213 | instance.once('addHandler', addHandlerTriggered);
214 | instance.once('test', () => {});
215 |
216 | expect(addHandlerTriggered).toHaveBeenCalledTimes(1);
217 | });
218 |
219 | it('adding addHandler handler should not emit itself', () => {
220 | const instance = new Emittr();
221 | const addHandlerTriggered = jasmine.createSpy();
222 | instance.once('addHandler', addHandlerTriggered);
223 |
224 | expect(addHandlerTriggered).not.toHaveBeenCalled();
225 | });
226 |
227 | it('should throw if adding non-function', () => {
228 | const instance = new Emittr();
229 |
230 | let err: any = null;
231 |
232 | try {
233 | // @ts-expect-error for testing purposes
234 | instance.once('test', {});
235 | } catch (error) {
236 | err = error;
237 | }
238 | expect(err instanceof TypeError).toBeTruthy();
239 | });
240 | });
241 |
242 | describe('prependOnce', () => {
243 | it('should add event handler to the list', () => {
244 | const instance = new Emittr();
245 | instance.prependOnce('test', () => {});
246 |
247 | // @ts-expect-error for testing purposes
248 | expect(Array.isArray(instance._handlers.test)).toBeTruthy();
249 | // @ts-expect-error for testing purposes
250 | expect(instance._handlers.test.length).toBe(1);
251 | });
252 |
253 | it('should return emitter instance', () => {
254 | const instance = new Emittr();
255 | const handler1 = jasmine.createSpy();
256 | expect(instance.prependOnce('test', handler1)).toBe(instance);
257 | });
258 |
259 | it('should add handlers to the start', () => {
260 | const instance = new Emittr();
261 | const handler1 = jasmine.createSpy();
262 | const handler2 = jasmine.createSpy();
263 | instance.prependOnce('test', handler1);
264 | instance.prependOnce('test', handler2);
265 |
266 | // @ts-expect-error for testing purposes
267 | expect(instance._handlers.test[1].handler).toBe(handler1);
268 | // @ts-expect-error for testing purposes
269 | expect(instance._handlers.test[0].handler).toBe(handler2);
270 | });
271 |
272 | it('should emit addHandler event', () => {
273 | const instance = new Emittr();
274 | const addHandlerTriggered = jasmine.createSpy();
275 | instance.prependOnce('addHandler', addHandlerTriggered);
276 | instance.prependOnce('test', () => {});
277 |
278 | expect(addHandlerTriggered).toHaveBeenCalledTimes(1);
279 | });
280 |
281 | it('adding addHandler handler should not emit itself', () => {
282 | const instance = new Emittr();
283 | const addHandlerTriggered = jasmine.createSpy();
284 | instance.prependOnce('addHandler', addHandlerTriggered);
285 |
286 | expect(addHandlerTriggered).not.toHaveBeenCalled();
287 | });
288 |
289 | it('should throw if adding non-function', () => {
290 | const instance = new Emittr();
291 |
292 | let err: any = null;
293 |
294 | try {
295 | // @ts-expect-error for testing purposes
296 | instance.prependOnce('test', {});
297 | } catch (error) {
298 | err = error;
299 | }
300 | expect(err instanceof TypeError).toBeTruthy();
301 | });
302 | });
303 |
304 | describe('off', () => {
305 | it('should throw if passing non-function handler', () => {
306 | const instance = new Emittr();
307 |
308 | let err: any = null;
309 |
310 | try {
311 | // @ts-expect-error for testing purposes
312 | instance.off('test', {});
313 | } catch (error) {
314 | err = error;
315 | }
316 | expect(err instanceof TypeError).toBeTruthy();
317 | });
318 |
319 | it('should return emitter instance', () => {
320 | const instance = new Emittr();
321 | const handler1 = jasmine.createSpy();
322 | expect(instance.off('test', handler1)).toBe(instance);
323 | });
324 |
325 | it('should remove handler', () => {
326 | const instance = new Emittr();
327 | const handler1 = jasmine.createSpy();
328 | instance.on('test', handler1);
329 | // @ts-expect-error for testing purposes
330 | expect(instance._handlers.test[0]).toBe(handler1);
331 | expect(instance.off('test', handler1)).toBe(instance);
332 | // @ts-expect-error for testing purposes
333 | expect(instance._handlers.test.length).toBe(0);
334 | });
335 |
336 | it('should remove once handler', () => {
337 | const instance = new Emittr();
338 | const handler1 = jasmine.createSpy();
339 | instance.once('test', handler1);
340 | // @ts-expect-error for testing purposes
341 | expect(instance._handlers.test[0].handler).toBe(handler1);
342 | expect(instance.off('test', handler1)).toBe(instance);
343 | // @ts-expect-error for testing purposes
344 | expect(instance._handlers.test.length).toBe(0);
345 | });
346 |
347 | it('should remove handler', () => {
348 | const instance = new Emittr();
349 | const handler1 = jasmine.createSpy();
350 | const handler2 = jasmine.createSpy();
351 | instance.on('test', handler1);
352 | instance.on('test', handler2);
353 | // @ts-expect-error for testing purposes
354 | expect(instance._handlers.test[1]).toBe(handler2);
355 | expect(instance.off('test', handler1)).toBe(instance);
356 | // @ts-expect-error for testing purposes
357 | expect(instance._handlers.test[0]).toBe(handler2);
358 | });
359 |
360 | it('should remove once handler', () => {
361 | const instance = new Emittr();
362 | const handler1 = jasmine.createSpy();
363 | const handler2 = jasmine.createSpy();
364 | instance.once('test', handler1);
365 | instance.once('test', handler2);
366 | // @ts-expect-error for testing purposes
367 | expect(instance._handlers.test[1].handler).toBe(handler2);
368 | expect(instance.off('test', handler1)).toBe(instance);
369 | // @ts-expect-error for testing purposes
370 | expect(instance._handlers.test[0].handler).toBe(handler2);
371 | });
372 |
373 | it('should emit removeHandler event', () => {
374 | const instance = new Emittr();
375 | const removeHandler = jasmine.createSpy();
376 | instance.on('removeHandler', removeHandler);
377 | const handler1 = jasmine.createSpy();
378 | const handler2 = jasmine.createSpy();
379 | instance.once('test', handler1);
380 |
381 | instance.off('test', handler2);
382 | instance.off('test', handler1);
383 |
384 | expect(removeHandler).toHaveBeenCalledTimes(1);
385 | expect(removeHandler.calls.argsFor(0)[0]).toBe('test');
386 | expect(removeHandler.calls.argsFor(0)[1]).toBe(handler1);
387 | });
388 | });
389 |
390 | describe('removeAllHandlers', () => {
391 | it('should remove all assigned handlers', () => {
392 | const instance = new Emittr();
393 |
394 | const removeHandler = jasmine.createSpy();
395 | instance.on('removeHandler', removeHandler);
396 |
397 | const handler1 = jasmine.createSpy();
398 | const handler2 = jasmine.createSpy();
399 | const handler3 = jasmine.createSpy();
400 |
401 | instance.on('test1', handler1).on('test2', handler2).on('test3', handler3);
402 |
403 | // @ts-expect-error for testing purposes
404 | expect(instance._handlers.test1.length).toBe(1);
405 | // @ts-expect-error for testing purposes
406 | expect(instance._handlers.test2.length).toBe(1);
407 | // @ts-expect-error for testing purposes
408 | expect(instance._handlers.test3.length).toBe(1);
409 | // @ts-expect-error for testing purposes
410 | expect(Object.keys(instance._handlers).length).toBe(4);
411 |
412 | instance.removeAllHandlers();
413 |
414 | // @ts-expect-error for testing purposes
415 | expect(Object.keys(instance._handlers).length).toBe(0);
416 |
417 | expect(removeHandler).toHaveBeenCalledTimes(3);
418 | expect(removeHandler.calls.argsFor(0)[0]).toBe('test1');
419 | expect(removeHandler.calls.argsFor(0)[1]).toBe(handler1);
420 | });
421 | });
422 |
423 | describe('emit', () => {
424 | it('should call handler', () => {
425 | const instance = new Emittr();
426 |
427 | const handler1 = jasmine.createSpy();
428 | instance.on('test', handler1);
429 |
430 | instance.emit('test', 'Hello', 'world!');
431 | expect(handler1).toHaveBeenCalledTimes(1);
432 | expect(handler1.calls.argsFor(0)[0]).toBe('Hello');
433 | expect(handler1.calls.argsFor(0)[1]).toBe('world!');
434 |
435 | instance.emit('test', 'Hello', 'world!');
436 | expect(handler1).toHaveBeenCalledTimes(2);
437 | });
438 |
439 | it('should call once handler only once', () => {
440 | const instance = new Emittr();
441 |
442 | const handler1 = jasmine.createSpy();
443 | const handler2 = jasmine.createSpy();
444 | instance.on('test', handler1);
445 | instance.once('test', handler2);
446 |
447 | instance.emit('test', 'Hello', 'world!');
448 | expect(handler1).toHaveBeenCalledTimes(1);
449 | expect(handler2).toHaveBeenCalledTimes(1);
450 |
451 | instance.emit('test', 'Hello', 'world!');
452 | expect(handler1).toHaveBeenCalledTimes(2);
453 | expect(handler2).toHaveBeenCalledTimes(1);
454 | });
455 |
456 | it('should call handler in order they added', () => {
457 | const instance = new Emittr();
458 | const handler1 = jasmine.createSpy();
459 | const handler2 = jasmine.createSpy();
460 | const handler3 = jasmine.createSpy();
461 | instance.on('test', handler2);
462 | instance.on('test', handler3);
463 | instance.prependOn('test', handler1);
464 |
465 | instance.emit('test', 'Hello', 'world!');
466 | expect(handler1).toHaveBeenCalledBefore(handler2);
467 | expect(handler2).toHaveBeenCalledBefore(handler3);
468 | });
469 | });
470 | });
471 |
--------------------------------------------------------------------------------
/tests/Loop.spec.ts:
--------------------------------------------------------------------------------
1 | import Loop from '../src/Loop';
2 |
3 | describe('Loop', function () {
4 | // eslint-disable-next-line unicorn/consistent-function-scoping
5 | const getTarget = () => ({
6 | _unmounted: false,
7 | randomField: 'Hello World!',
8 | update: jasmine.createSpy(),
9 | });
10 |
11 | describe('.isActive', function () {
12 | it('should return boolean', function () {
13 | expect(Loop.isActive).toBeFalsy();
14 | });
15 |
16 | it('should return actual state', function () {
17 | expect(Loop.isActive).toBeFalsy();
18 | const target = getTarget();
19 | Loop.addTarget(target);
20 | expect(Loop.isActive).toBeTruthy();
21 | Loop.removeTarget(target);
22 | expect(Loop.isActive).toBeFalsy();
23 | });
24 | });
25 |
26 | describe('.start()', function () {
27 | it('should return the instance', function () {
28 | expect(Loop.start()).toBe(Loop);
29 | });
30 |
31 | it('should not start the loop if no targets registered', function () {
32 | Loop.start();
33 | expect(Loop.isActive).toBeFalsy();
34 | });
35 |
36 | it('should start the loop if has targets', function () {
37 | const target = getTarget();
38 | Loop.addTarget(target, true);
39 | expect(Loop.isActive).toBeFalsy();
40 |
41 | Loop.start();
42 | expect(Loop.isActive).toBeTruthy();
43 |
44 | Loop.removeTarget(target);
45 | expect(Loop.isActive).toBeFalsy();
46 | });
47 |
48 | it("should cancel previous animation frame if it somewhy wasn't", function (done) {
49 | const target = getTarget();
50 | Loop.addTarget(target, true);
51 | expect(Loop.isActive).toBeFalsy();
52 |
53 | const spy = jasmine.createSpy();
54 | // @ts-expect-error for testing purposes
55 | Loop.animationFrameID = requestAnimationFrame(spy);
56 |
57 | Loop.start();
58 | expect(Loop.isActive).toBeTruthy();
59 |
60 | setTimeout(() => {
61 | expect(Loop.isActive).toBeTruthy();
62 | expect(spy).not.toHaveBeenCalled();
63 |
64 | Loop.removeTarget(target);
65 | expect(Loop.isActive).toBeFalsy();
66 | done();
67 | }, 60);
68 | });
69 | });
70 |
71 | describe('.stop()', function () {
72 | it('should return the instance', function () {
73 | expect(Loop.stop()).toBe(Loop);
74 | });
75 |
76 | it('should stop the loop if ', function (done) {
77 | const target = getTarget();
78 | Loop.addTarget(target);
79 | expect(Loop.isActive).toBeTruthy();
80 |
81 | Loop.stop();
82 | expect(Loop.isActive).toBeFalsy();
83 |
84 | setTimeout(() => {
85 | expect(target.update).not.toHaveBeenCalled();
86 |
87 | Loop.removeTarget(target);
88 | expect(Loop.isActive).toBeFalsy();
89 | done();
90 | }, 60);
91 | });
92 | });
93 |
94 | describe('.addTarget()', function () {
95 | it('should return the instance', function () {
96 | const target = getTarget();
97 | expect(Loop.addTarget(target)).toBe(Loop);
98 | Loop.removeTarget(target);
99 | });
100 |
101 | it('should add the target to targets list', function () {
102 | const target = getTarget();
103 | Loop.addTarget(target);
104 | // @ts-expect-error for testing purposes
105 | expect(Loop.targets.length).toBe(1);
106 | // @ts-expect-error for testing purposes
107 | expect(Loop.targets[0]).toBe(target);
108 | Loop.removeTarget(target);
109 | });
110 |
111 | it('should not add the target twice', function () {
112 | const target = getTarget();
113 | Loop.addTarget(target).addTarget(target).addTarget(target);
114 | // @ts-expect-error for testing purposes
115 | expect(Loop.targets.length).toBe(1);
116 | // @ts-expect-error for testing purposes
117 | expect(Loop.targets[0]).toBe(target);
118 | Loop.removeTarget(target);
119 | });
120 |
121 | it('should start the loop if added first target', function () {
122 | const target = getTarget();
123 | Loop.addTarget(target);
124 | expect(Loop.isActive).toBeTruthy();
125 | Loop.removeTarget(target);
126 | });
127 |
128 | it('should not start the loop if added first target and 2nd parameter is true', function () {
129 | const target = getTarget();
130 | Loop.addTarget(target, true);
131 | expect(Loop.isActive).toBeFalsy();
132 | Loop.removeTarget(target);
133 | });
134 | });
135 |
136 | describe('.removeTarget()', function () {
137 | it('should return the instance', function () {
138 | const target = getTarget();
139 | expect(Loop.removeTarget(target)).toBe(Loop);
140 | });
141 |
142 | it('should remove the target from targets list', function () {
143 | const target = getTarget();
144 | Loop.addTarget(target);
145 | // @ts-expect-error for testing purposes
146 | expect(Loop.targets.length).toBe(1);
147 | // @ts-expect-error for testing purposes
148 | expect(Loop.targets[0]).toBe(target);
149 | Loop.removeTarget(target);
150 | // @ts-expect-error for testing purposes
151 | expect(Loop.targets.length).toBe(0);
152 | });
153 |
154 | it('should stop the loop if removed last target', function () {
155 | const target = getTarget();
156 | Loop.addTarget(target);
157 | expect(Loop.isActive).toBeTruthy();
158 | Loop.removeTarget(target);
159 | expect(Loop.isActive).toBeFalsy();
160 | });
161 | });
162 |
163 | describe('.rafCallback()', function () {
164 | it('should not fire the targets if loop is not active', function () {
165 | const target = getTarget();
166 | Loop.addTarget(target, true);
167 | // @ts-expect-error for testing purposes
168 | expect(Loop.rafCallback()).toBe(0);
169 | expect(Loop.isActive).toBeFalsy();
170 | Loop.removeTarget(target);
171 | });
172 | it('should fire the targets if loop is active and return new RAF ID', function () {
173 | const target = getTarget();
174 |
175 | let id;
176 |
177 | Loop.addTarget(target, true);
178 | // @ts-expect-error for testing purposes
179 | Loop._isActive = true;
180 | // @ts-expect-error for testing purposes
181 | expect((id = Loop.rafCallback())).not.toBe(0);
182 | expect(target.update).toHaveBeenCalled();
183 | cancelAnimationFrame(id);
184 | Loop.removeTarget(target);
185 | });
186 | });
187 | });
188 |
--------------------------------------------------------------------------------
/tests/ScrollbarThumb.spec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import * as simulant from 'simulant';
4 | import ScrollbarThumb from '../src/ScrollbarThumb';
5 | import { AXIS_DIRECTION } from '../src/types';
6 |
7 | describe('ScrollbarThumb', () => {
8 | const nodes: HTMLDivElement[] = [];
9 |
10 | const getNode = () => {
11 | const node = document.createElement('div');
12 | document.body.append(node);
13 | nodes.push(node);
14 |
15 | return node;
16 | };
17 |
18 | afterAll(() => {
19 | nodes.forEach((node) => node.remove());
20 | });
21 |
22 | it('should render a div by default', (done) => {
23 | ReactDOM.render(, getNode(), function () {
24 | expect(this.element instanceof HTMLDivElement).toBeTruthy();
25 | done();
26 | });
27 | });
28 |
29 | it('should pass rendered element ref to elementRef function', (done) => {
30 | let element: HTMLDivElement | null;
31 | ReactDOM.render(
32 | {
35 | element = ref;
36 | }}
37 | />,
38 | getNode(),
39 | function () {
40 | expect(element instanceof HTMLElement).toBe(true);
41 | done();
42 | }
43 | );
44 | });
45 |
46 | it('should render proper track with direction X axis', (done) => {
47 | ReactDOM.render(, getNode(), function () {
48 | expect(this.props.axis).toBe(AXIS_DIRECTION.X);
49 | expect(this.element.classList.contains('ScrollbarsCustom-ThumbX')).toBeTruthy();
50 | done();
51 | });
52 | });
53 |
54 | it('should render proper track with direction Y axis', (done) => {
55 | ReactDOM.render(, getNode(), function () {
56 | expect(this.props.axis).toBe(AXIS_DIRECTION.Y);
57 | expect(this.element.classList.contains('ScrollbarsCustom-ThumbY')).toBeTruthy();
58 | done();
59 | });
60 | });
61 |
62 | it('should apply className', (done) => {
63 | ReactDOM.render(
64 | ,
65 | getNode(),
66 | function () {
67 | expect(this.element.classList.contains('MyAwesomeClassName')).toBeTruthy();
68 | done();
69 | }
70 | );
71 | });
72 |
73 | it('should apply style', (done) => {
74 | ReactDOM.render(
75 | ,
76 | getNode(),
77 | function () {
78 | expect(this.element.style.width).toBe('100px');
79 | expect(this.element.style.height).toBe('200px');
80 | done();
81 | }
82 | );
83 | });
84 |
85 | it('should render if custom renderer passed', (done) => {
86 | const renderer = function (props) {
87 | return (
88 |
89 |
{props.children}
90 |
91 | );
92 | };
93 |
94 | ReactDOM.render(
95 | ,
96 | getNode(),
97 | function () {
98 | expect(this.element.parentElement.classList.contains('customTrack')).toBeTruthy();
99 |
100 | done();
101 | }
102 | );
103 | });
104 |
105 | it('should throw if renderer did not passed the element link', (done) => {
106 | const renderer = function (props) {
107 | return (
108 |
109 |
{props.children}
110 |
111 | );
112 | };
113 |
114 | class ErrorBoundary extends React.Component, { [key: string]: any }> {
115 | constructor(props) {
116 | super(props);
117 | this.state = { error: null, errorInfo: null };
118 | }
119 |
120 | componentDidCatch(error, errorInfo) {
121 | this.setState({
122 | error,
123 | errorInfo,
124 | });
125 | }
126 |
127 | render() {
128 | if (this.state.errorInfo) {
129 | return false;
130 | }
131 |
132 | return this.props.children;
133 | }
134 | }
135 |
136 | ReactDOM.render(
137 |
138 |
139 | ,
140 | getNode(),
141 | function () {
142 | setTimeout(() => {
143 | expect(this.state.error instanceof Error).toBeTruthy();
144 | expect(this.state.error.message).toBe(
145 | " Element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
146 | );
147 |
148 | done();
149 | }, 10);
150 | }
151 | );
152 | });
153 |
154 | it('should emit onDragStart on mousedown LMB', (done) => {
155 | const spy = jasmine.createSpy();
156 |
157 | ReactDOM.render(
158 | ,
163 | getNode(),
164 | function () {
165 | const { top, height, left, width } = this.element.getBoundingClientRect();
166 |
167 | simulant.fire(this.element, 'mousedown', {
168 | button: 0,
169 | clientY: top + height / 2,
170 | clientX: left + width / 2,
171 | });
172 |
173 | setTimeout(() => {
174 | expect(spy.calls.argsFor(0)[0].y).toBe(top);
175 | expect(spy.calls.argsFor(0)[0].x).toBe(left);
176 | done();
177 | }, 5);
178 | }
179 | );
180 | });
181 |
182 | it('should NOT emit onDragStart on mousedown not LMB', (done) => {
183 | const spy = jasmine.createSpy();
184 |
185 | ReactDOM.render(
186 | ,
191 | getNode(),
192 | function () {
193 | const { top, height, left, width } = this.element.getBoundingClientRect();
194 |
195 | simulant.fire(this.element, 'mousedown', {
196 | button: 1,
197 | clientY: top + height / 2,
198 | clientX: left + width / 2,
199 | });
200 |
201 | setTimeout(() => {
202 | expect(spy).not.toHaveBeenCalled();
203 | done();
204 | }, 5);
205 | }
206 | );
207 | });
208 |
209 | it('should emit onDragEnd on mouseup', (done) => {
210 | const start = jasmine.createSpy();
211 | const end = jasmine.createSpy();
212 |
213 | ReactDOM.render(
214 | ,
220 | getNode(),
221 | function () {
222 | const { top, height, left, width } = this.element.getBoundingClientRect();
223 |
224 | simulant.fire(this.element, 'mousedown', {
225 | button: 0,
226 | clientY: top + height / 2,
227 | clientX: left + width / 2,
228 | });
229 | setTimeout(() => {
230 | simulant.fire(document, 'mouseup', {
231 | button: 0,
232 | clientY: top + height / 2,
233 | clientX: left + width / 2,
234 | });
235 |
236 | setTimeout(() => {
237 | expect(start).toHaveBeenCalled();
238 | expect(end).toHaveBeenCalled();
239 | expect(start).toHaveBeenCalledBefore(end);
240 |
241 | expect(end.calls.argsFor(0)[0].y).toBe(top);
242 | expect(end.calls.argsFor(0)[0].x).toBe(left);
243 | done();
244 | }, 5);
245 | }, 5);
246 | }
247 | );
248 | });
249 |
250 | it('should emit onDrag on mousemove', (done) => {
251 | const start = jasmine.createSpy();
252 | const end = jasmine.createSpy();
253 | const drag = jasmine.createSpy();
254 |
255 | ReactDOM.render(
256 | ,
263 | getNode(),
264 | function () {
265 | const { top, height, left, width } = this.element.getBoundingClientRect();
266 |
267 | simulant.fire(this.element, 'mousedown', {
268 | button: 0,
269 | clientY: top + height / 2,
270 | clientX: left + width / 2,
271 | });
272 |
273 | setTimeout(() => {
274 | simulant.fire(document, 'mousemove', {
275 | button: 0,
276 | clientY: top + height / 2,
277 | clientX: left + width / 2,
278 | });
279 | simulant.fire(document, 'mouseup', {
280 | button: 0,
281 | clientY: top + height / 2,
282 | clientX: left + width / 2,
283 | });
284 |
285 | setTimeout(() => {
286 | expect(drag).toHaveBeenCalled();
287 | expect(start).toHaveBeenCalled();
288 | expect(end).toHaveBeenCalled();
289 | expect(start).toHaveBeenCalledBefore(drag);
290 | expect(drag).toHaveBeenCalledBefore(end);
291 |
292 | expect(drag.calls.argsFor(0)[0].y).toBe(top);
293 | expect(drag.calls.argsFor(0)[0].x).toBe(left);
294 | done();
295 | }, 5);
296 | }, 5);
297 | }
298 | );
299 | });
300 |
301 | it('should end the dragging if element was removed', (done) => {
302 | const start = jasmine.createSpy();
303 | const end = jasmine.createSpy();
304 | const drag = jasmine.createSpy();
305 |
306 | ReactDOM.render(
307 | ,
314 | getNode(),
315 | function () {
316 | const { top, height, left, width } = this.element.getBoundingClientRect();
317 |
318 | simulant.fire(this.element, 'mousedown', {
319 | button: 0,
320 | clientY: top + height / 2,
321 | clientX: left + width / 2,
322 | });
323 |
324 | this.element = null;
325 | simulant.fire(document, 'mousemove', {
326 | button: 0,
327 | clientY: top + height / 2,
328 | clientX: left + width / 2,
329 | });
330 |
331 | setTimeout(() => {
332 | expect(drag).not.toHaveBeenCalled();
333 | expect(start).toHaveBeenCalled();
334 | expect(end).toHaveBeenCalled();
335 |
336 | expect(start).toHaveBeenCalledBefore(end);
337 | done();
338 | }, 5);
339 | }
340 | );
341 | });
342 |
343 | it('handleDragStart while element was removed should not emit callbacks', (done) => {
344 | const start = jasmine.createSpy();
345 | const end = jasmine.createSpy();
346 | const drag = jasmine.createSpy();
347 |
348 | ReactDOM.render(
349 | ,
356 | getNode(),
357 | function () {
358 | const { top, height, left, width } = this.element.getBoundingClientRect();
359 |
360 | const elt = this.element;
361 | this.element = null;
362 | simulant.fire(elt, 'mousedown', {
363 | button: 0,
364 | clientY: top + height / 2,
365 | clientX: left + width / 2,
366 | });
367 |
368 | setTimeout(() => {
369 | expect(drag).not.toHaveBeenCalled();
370 | expect(start).not.toHaveBeenCalled();
371 | expect(end).toHaveBeenCalled();
372 | done();
373 | }, 5);
374 | }
375 | );
376 | });
377 |
378 | it('while dragging element should have the `dragging` classname', (done) => {
379 | ReactDOM.render(
380 | ,
381 | getNode(),
382 | function () {
383 | const { top, height, left, width } = this.element.getBoundingClientRect();
384 |
385 | simulant.fire(this.element, 'mousedown', {
386 | button: 0,
387 | clientY: top + height / 2,
388 | clientX: left + width / 2,
389 | });
390 |
391 | setTimeout(() => {
392 | expect(this.element.classList.contains('dragging')).toBeTruthy();
393 | done();
394 | }, 5);
395 | }
396 | );
397 | });
398 |
399 | it('should end the dragging when component is unmounted', (done) => {
400 | const end = jasmine.createSpy();
401 | const node = getNode();
402 |
403 | ReactDOM.render(
404 | ,
405 | node,
406 | function () {
407 | const { top, height, left, width } = this.element.getBoundingClientRect();
408 |
409 | simulant.fire(this.element, 'mousedown', {
410 | button: 0,
411 | clientY: top + height / 2,
412 | clientX: left + width / 2,
413 | });
414 | setTimeout(() => {
415 | simulant.fire(document, 'mousemove', {
416 | button: 0,
417 | clientY: top + height / 2,
418 | clientX: left + width / 2,
419 | });
420 |
421 | setTimeout(() => {
422 | ReactDOM.unmountComponentAtNode(node);
423 | expect(end).toHaveBeenCalled();
424 |
425 | done();
426 | }, 5);
427 | }, 5);
428 | }
429 | );
430 | });
431 |
432 | it('should set document`s user-select to none and replace onselectstart callback', (done) => {
433 | ReactDOM.render(
434 | ,
435 | getNode(),
436 | function () {
437 | const { top, height, left, width } = this.element.getBoundingClientRect();
438 |
439 | simulant.fire(this.element, 'mousedown', {
440 | button: 0,
441 | clientY: top + height / 2,
442 | clientX: left + width / 2,
443 | });
444 |
445 | setTimeout(() => {
446 | expect(document.body.style.userSelect).toBe('none');
447 | expect(document.onselectstart).toBe(null);
448 |
449 | done();
450 | }, 50);
451 | }
452 | );
453 | });
454 | });
455 |
--------------------------------------------------------------------------------
/tests/ScrollbarTrack.spec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import * as simulant from 'simulant';
4 | import ScrollbarTrack from '../src/ScrollbarTrack';
5 | import { AXIS_DIRECTION } from '../src/types';
6 |
7 | describe('ScrollbarTrack', () => {
8 | const nodes: HTMLDivElement[] = [];
9 |
10 | const getNode = () => {
11 | const node = document.createElement('div');
12 | document.body.append(node);
13 | nodes.push(node);
14 |
15 | return node;
16 | };
17 |
18 | afterAll(() => {
19 | nodes.forEach((node) => node.remove());
20 | });
21 |
22 | it('should render a div by default', (done) => {
23 | ReactDOM.render(, getNode(), function () {
24 | expect(this.element instanceof HTMLDivElement).toBeTruthy();
25 | done();
26 | });
27 | });
28 |
29 | it('should pass rendered element ref to elementRef function', (done) => {
30 | let element: HTMLDivElement | null;
31 | ReactDOM.render(
32 | {
35 | element = ref;
36 | }}
37 | />,
38 | getNode(),
39 | function () {
40 | expect(element instanceof HTMLDivElement).toBeTruthy();
41 | done();
42 | }
43 | );
44 | });
45 |
46 | it('should render proper track with direction X axis', (done) => {
47 | ReactDOM.render(, getNode(), function () {
48 | expect(this.props.axis).toBe(AXIS_DIRECTION.X);
49 | expect(this.element.classList.contains('ScrollbarsCustom-TrackX')).toBeTruthy();
50 | done();
51 | });
52 | });
53 |
54 | it('should render proper track with direction Y axis', (done) => {
55 | ReactDOM.render(, getNode(), function () {
56 | expect(this.props.axis).toBe(AXIS_DIRECTION.Y);
57 | expect(this.element.classList.contains('ScrollbarsCustom-TrackY')).toBeTruthy();
58 | done();
59 | });
60 | });
61 |
62 | it('should apply className', (done) => {
63 | ReactDOM.render(
64 | ,
65 | getNode(),
66 | function () {
67 | expect(this.element.classList.contains('MyAwesomeClassName')).toBeTruthy();
68 | done();
69 | }
70 | );
71 | });
72 |
73 | it('should apply style', (done) => {
74 | ReactDOM.render(
75 | ,
76 | getNode(),
77 | function () {
78 | expect(this.element.style.width).toBe('100px');
79 | expect(this.element.style.height).toBe('200px');
80 | done();
81 | }
82 | );
83 | });
84 |
85 | it('should render if custom renderer passed', (done) => {
86 | const renderer = function (props) {
87 | return (
88 |
89 |
{props.children}
90 |
91 | );
92 | };
93 |
94 | ReactDOM.render(
95 | ,
96 | getNode(),
97 | function () {
98 | expect(this.element.parentElement.classList.contains('customTrack')).toBeTruthy();
99 |
100 | done();
101 | }
102 | );
103 | });
104 |
105 | it('should throw if renderer did not passed the element link', (done) => {
106 | const renderer = function (props) {
107 | return (
108 |
109 |
{props.children}
110 |
111 | );
112 | };
113 |
114 | class ErrorBoundary extends React.Component {
115 | constructor(props) {
116 | super(props);
117 | this.state = { error: null, errorInfo: null };
118 | }
119 |
120 | componentDidCatch(error, errorInfo) {
121 | this.setState({
122 | error,
123 | errorInfo,
124 | });
125 | }
126 |
127 | render() {
128 | if (this.state.errorInfo) {
129 | return false;
130 | }
131 |
132 | return this.props.children;
133 | }
134 | }
135 |
136 | ReactDOM.render(
137 |
138 |
139 | ,
140 | getNode(),
141 | function () {
142 | setTimeout(() => {
143 | expect(this.state.error instanceof Error).toBeTruthy();
144 | expect(this.state.error.message).toBe(
145 | "Element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function."
146 | );
147 |
148 | done();
149 | }, 10);
150 | }
151 | );
152 | });
153 |
154 | it('should handle click event', (done) => {
155 | const spy = jasmine.createSpy();
156 | ReactDOM.render(
157 | ,
158 | getNode(),
159 | function () {
160 | const { top, height, left, width } = this.element.getBoundingClientRect();
161 |
162 | simulant.fire(this.element, 'click', {
163 | button: 0,
164 | offsetY: top + height / 2,
165 | offsetX: left + width / 2,
166 | });
167 |
168 | expect(spy).toHaveBeenCalled();
169 |
170 | done();
171 | }
172 | );
173 | });
174 |
175 | it('should call onClick with proper parameters for X axis', (done) => {
176 | const spy = jasmine.createSpy();
177 | ReactDOM.render(
178 | ,
179 | getNode(),
180 | function () {
181 | const { top, height, left, width } = this.element.getBoundingClientRect();
182 |
183 | simulant.fire(this.element, 'click', {
184 | button: 0,
185 | clientY: top + height / 2,
186 | clientX: left + width / 2,
187 | });
188 |
189 | expect(spy.calls.argsFor(0)[0] instanceof MouseEvent).toBeTruthy();
190 | expect(spy.calls.argsFor(0)[1].axis).toBe(AXIS_DIRECTION.X);
191 | expect(spy.calls.argsFor(0)[1].offset).toBe(50);
192 |
193 | done();
194 | }
195 | );
196 | });
197 |
198 | it('should call onClick with proper parameters for Y axis', (done) => {
199 | const spy = jasmine.createSpy();
200 | ReactDOM.render(
201 | ,
202 | getNode(),
203 | function () {
204 | const { top, height, left, width } = this.element.getBoundingClientRect();
205 |
206 | simulant.fire(this.element, 'click', {
207 | button: 0,
208 | clientY: top + height / 2,
209 | clientX: left + width / 2,
210 | });
211 |
212 | expect(spy.calls.argsFor(0)[0] instanceof MouseEvent).toBeTruthy();
213 | expect(spy.calls.argsFor(0)[1].axis).toBe(AXIS_DIRECTION.Y);
214 | expect(spy.calls.argsFor(0)[1].offset).toBe(100);
215 |
216 | done();
217 | }
218 | );
219 | });
220 |
221 | it('should not call onClick callback if element was deleted/unmounted or clicked not LMB', (done) => {
222 | const spy = jasmine.createSpy();
223 | ReactDOM.render(
224 | ,
225 | getNode(),
226 | function () {
227 | simulant.fire(this.element, 'click', {
228 | button: 1,
229 | });
230 |
231 | expect(spy).not.toHaveBeenCalled();
232 |
233 | this.handleClick(new MouseEvent('click'));
234 |
235 | expect(spy).not.toHaveBeenCalled();
236 |
237 | done();
238 | }
239 | );
240 | });
241 | });
242 |
--------------------------------------------------------------------------------
/tests/util.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-shadow */
2 | import * as React from 'react';
3 | import * as ReactDOM from 'react-dom';
4 | import { ElementPropsWithElementRef } from '../src/types';
5 | import {
6 | _dbgGetDocument,
7 | _dbgSetDocument,
8 | calcScrollForThumbOffset,
9 | calcThumbOffset,
10 | calcThumbSize,
11 | getInnerHeight,
12 | getInnerWidth,
13 | getScrollbarWidth,
14 | renderDivWithRenderer,
15 | shouldReverseRtlScroll,
16 | uuid,
17 | } from '../src/util';
18 |
19 | describe('util', () => {
20 | afterAll(() => {
21 | delete shouldReverseRtlScroll._cache;
22 | delete getScrollbarWidth._cache;
23 | });
24 |
25 | describe('calcThumbSize', () => {
26 | it('should return number', () => {
27 | expect(typeof calcThumbSize(400, 200, 200, 30, 0)).toBe('number');
28 | });
29 |
30 | it('should return 0 if viewport size >= contentSize', () => {
31 | expect(calcThumbSize(100, 200, 200, 30, 0)).toBe(0);
32 | expect(calcThumbSize(200, 200, 200, 30, 0)).toBe(0);
33 | });
34 |
35 | it('should return proper values', () => {
36 | expect(calcThumbSize(200, 100, 100)).toBe(50);
37 | expect(calcThumbSize(1000, 100, 100)).toBe(10);
38 | });
39 |
40 | it('should no exceed minimal value', () => {
41 | expect(calcThumbSize(1000, 100, 100, 30)).toBe(30);
42 | });
43 | it('should no exceed maximal value', () => {
44 | expect(calcThumbSize(200, 100, 100, undefined, 30)).toBe(30);
45 | });
46 | });
47 |
48 | describe('calcThumbOffset', () => {
49 | it('should return number', () => {
50 | expect(typeof calcThumbOffset(0, 0, 0, 0, 0)).toBe('number');
51 | });
52 |
53 | it('should return 0 if viewport size >= contentSize', () => {
54 | expect(calcThumbOffset(100, 200, 200, 200, 50)).toBe(0);
55 | expect(calcThumbOffset(200, 200, 200, 100, 50)).toBe(0);
56 | });
57 |
58 | it('should return 0 if thumb size === 0', () => {
59 | expect(calcThumbOffset(100, 200, 200, 0, 50)).toBe(0);
60 | });
61 |
62 | it('should return 0 if scroll === 0', () => {
63 | expect(calcThumbOffset(100, 200, 200, 0, 0)).toBe(0);
64 | });
65 |
66 | it('should return proper values', () => {
67 | expect(calcThumbOffset(1000, 500, 500, 250, 100)).toBe(50);
68 | expect(calcThumbOffset(200, 100, 100, 50, 100)).toBe(50);
69 | });
70 | });
71 |
72 | describe('calcScrollForThumbOffset', () => {
73 | it('should return number', () => {
74 | expect(typeof calcScrollForThumbOffset(0, 0, 0, 0, 0)).toBe('number');
75 | });
76 |
77 | it('should return 0 if viewport size >= contentSize', () => {
78 | expect(calcScrollForThumbOffset(100, 200, 200, 200, 50)).toBe(0);
79 | expect(calcScrollForThumbOffset(200, 200, 200, 100, 50)).toBe(0);
80 | });
81 |
82 | it('should return 0 if thumb size === 0', () => {
83 | expect(calcScrollForThumbOffset(100, 200, 200, 0, 50)).toBe(0);
84 | });
85 |
86 | it('should return 0 if thumb offset === 0', () => {
87 | expect(calcScrollForThumbOffset(100, 200, 200, 0, 50)).toBe(0);
88 | });
89 |
90 | it('should return proper values', () => {
91 | expect(calcScrollForThumbOffset(1000, 500, 500, 250, 50)).toBe(100);
92 | expect(calcScrollForThumbOffset(200, 100, 100, 50, 50)).toBe(100);
93 | });
94 | });
95 |
96 | describe('uuid', () => {
97 | it('should generate valid UUID v4', () => {
98 | expect(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$/.test(uuid())).toBe(true);
99 | });
100 |
101 | it('should generate unique UUID', () => {
102 | for (let i = 0; i < 50; i++) {
103 | expect(uuid()).not.toBe(uuid());
104 | }
105 | });
106 | });
107 |
108 | describe('in case box-sizing: border-box', () => {
109 | let div: HTMLDivElement;
110 |
111 | beforeEach(() => {
112 | div = document.createElement('div');
113 | div.style.width = '100.5px';
114 | div.style.height = '200.5px';
115 | div.style.padding = '25.125px';
116 | div.style.boxSizing = 'border-box';
117 |
118 | document.body.append(div);
119 | });
120 |
121 | afterEach(() => {
122 | div.remove();
123 | });
124 |
125 | describe('getInnerHeight()', () => {
126 | it('should return number', () => {
127 | expect(typeof getInnerHeight(div)).toBe('number');
128 | });
129 |
130 | it('should return float for values with floating point height', () => {
131 | expect(getInnerHeight(div) % 1 !== 0).toBe(true);
132 | });
133 |
134 | it('should return proper height', () => {
135 | expect(getInnerHeight(div)).toBe(150.25);
136 | });
137 |
138 | it('should return 0 for unattached element', () => {
139 | const div = document.createElement('div');
140 | div.style.width = '100.25px';
141 | div.style.height = '200.25px';
142 |
143 | expect(getInnerHeight(div)).toBe(0);
144 | });
145 |
146 | it('should return 0 for blocks with display=none||inline', () => {
147 | div.style.padding = '';
148 | div.style.width = '';
149 | div.style.height = '';
150 |
151 | div.style.display = 'none';
152 | expect(getInnerHeight(div)).toBe(0);
153 |
154 | div.style.display = 'inline';
155 | expect(getInnerHeight(div)).toBe(0);
156 | });
157 |
158 | it('should return 0 for blocks width 0 size and padding', () => {
159 | const div = document.createElement('div');
160 | div.style.width = '0';
161 | div.style.height = '0';
162 | div.style.padding = '25.375px';
163 | div.style.boxSizing = 'border-box';
164 |
165 | expect(getInnerHeight(div)).toBe(0);
166 | });
167 |
168 | it('should return proper height if padding has not been set', () => {
169 | div.style.padding = '';
170 | expect(getInnerHeight(div)).toBe(200.5);
171 | });
172 |
173 | it('should return proper height if padding has been set partially', () => {
174 | div.style.padding = '';
175 | div.style.paddingTop = '10.375px';
176 | expect(getInnerHeight(div)).toBe(190.125);
177 | });
178 | });
179 |
180 | describe('getInnerWidth()', () => {
181 | it('should return number', () => {
182 | expect(typeof getInnerWidth(div)).toBe('number');
183 | });
184 |
185 | it('should return float for values with floating point height', () => {
186 | expect(getInnerWidth(div) % 1 !== 0).toBe(true);
187 | });
188 |
189 | it('should return proper width', () => {
190 | expect(getInnerWidth(div)).toBe(50.25);
191 | });
192 |
193 | it('should return 0 for unattached element', () => {
194 | const div = document.createElement('div');
195 | div.style.width = '100.25px';
196 | div.style.height = '200.25px';
197 |
198 | expect(getInnerWidth(div)).toBe(0);
199 | });
200 |
201 | it('should return 0 for blocks with display=none||inline', () => {
202 | div.style.padding = '';
203 | div.style.width = '';
204 | div.style.height = '';
205 |
206 | div.style.display = 'none';
207 | expect(getInnerWidth(div)).toBe(0);
208 |
209 | div.style.display = 'inline';
210 | expect(getInnerWidth(div)).toBe(0);
211 | });
212 |
213 | it('should return 0 for blocks width 0 size and padding', () => {
214 | const div = document.createElement('div');
215 | div.style.width = '0';
216 | div.style.height = '0';
217 | div.style.padding = '25.375px';
218 | div.style.boxSizing = 'border-box';
219 |
220 | expect(getInnerWidth(div)).toBe(0);
221 | });
222 |
223 | it('should return proper width if padding has not been set', () => {
224 | div.style.padding = '';
225 | expect(getInnerWidth(div)).toBe(100.5);
226 | });
227 |
228 | it('should return proper width if padding has been set partially', () => {
229 | div.style.padding = '';
230 | div.style.paddingLeft = '10.375px';
231 | expect(getInnerWidth(div)).toBe(90.125);
232 | });
233 | });
234 | });
235 |
236 | describe('in case box-sizing: content-box', () => {
237 | let div: HTMLDivElement;
238 |
239 | beforeEach(() => {
240 | div = document.createElement('div');
241 | div.style.width = '100.5px';
242 | div.style.height = '200.5px';
243 | div.style.padding = '25.125px';
244 | div.style.boxSizing = 'content-box';
245 |
246 | document.body.append(div);
247 | });
248 |
249 | afterEach(() => {
250 | div.remove();
251 | });
252 |
253 | describe('getInnerHeight()', () => {
254 | it('should return number', () => {
255 | expect(typeof getInnerHeight(div)).toBe('number');
256 | });
257 |
258 | it('should return float for values with floating point height', () => {
259 | expect(getInnerHeight(div) % 1 !== 0).toBe(true);
260 | });
261 |
262 | it('should return proper height', () => {
263 | expect(getInnerHeight(div)).toBe(200.5);
264 | });
265 |
266 | it('should return 0 for unattached element', () => {
267 | const div = document.createElement('div');
268 | div.style.width = '100.25px';
269 | div.style.height = '200.25px';
270 |
271 | expect(getInnerHeight(div)).toBe(0);
272 | });
273 |
274 | it('should return 0 for blocks with display=none||inline', () => {
275 | div.style.padding = '';
276 | div.style.width = '';
277 | div.style.height = '';
278 | div.style.boxSizing = '';
279 |
280 | div.style.display = 'none';
281 | expect(getInnerHeight(div)).toBe(0);
282 |
283 | div.style.display = 'inline';
284 | expect(getInnerHeight(div)).toBe(0);
285 | });
286 |
287 | it('should return 0 for blocks width 0 size and padding', () => {
288 | const div = document.createElement('div');
289 | div.style.width = '0';
290 | div.style.height = '0';
291 | div.style.padding = '25.375px';
292 |
293 | expect(getInnerHeight(div)).toBe(0);
294 | });
295 |
296 | it('should return proper height if padding has not been set', () => {
297 | div.style.padding = '';
298 | expect(getInnerHeight(div)).toBe(200.5);
299 | });
300 |
301 | it('should return proper height if padding has been set partially', () => {
302 | div.style.padding = '';
303 | div.style.paddingTop = '10.375px';
304 | expect(getInnerHeight(div)).toBe(200.5);
305 | });
306 | });
307 |
308 | describe('getInnerWidth()', () => {
309 | it('should return number', () => {
310 | expect(typeof getInnerWidth(div)).toBe('number');
311 | });
312 |
313 | it('should return float for values with floating point height', () => {
314 | expect(getInnerWidth(div) % 1 !== 0).toBe(true);
315 | });
316 |
317 | it('should return proper width', () => {
318 | expect(getInnerWidth(div)).toBe(100.5);
319 | });
320 |
321 | it('should return 0 for unattached element', () => {
322 | const div = document.createElement('div');
323 | div.style.width = '100.25px';
324 | div.style.height = '200.25px';
325 |
326 | expect(getInnerWidth(div)).toBe(0);
327 | });
328 |
329 | it('should return 0 for blocks with display=none||inline', () => {
330 | div.style.padding = '';
331 | div.style.width = '';
332 | div.style.height = '';
333 | div.style.boxSizing = '';
334 |
335 | div.style.display = 'none';
336 | expect(getInnerWidth(div)).toBe(0);
337 |
338 | div.style.display = 'inline';
339 | expect(getInnerWidth(div)).toBe(0);
340 | });
341 |
342 | it('should return 0 for blocks width 0 size and padding', () => {
343 | const div = document.createElement('div');
344 | div.style.width = '0';
345 | div.style.height = '0';
346 | div.style.padding = '25.375px';
347 |
348 | expect(getInnerWidth(div)).toBe(0);
349 | });
350 |
351 | it('should return proper width if padding has not been set', () => {
352 | div.style.padding = '';
353 | expect(getInnerWidth(div)).toBe(100.5);
354 | });
355 |
356 | it('should return proper width if padding has been set partially', () => {
357 | div.style.padding = '';
358 | div.style.paddingLeft = '10.375px';
359 | expect(getInnerWidth(div)).toBe(100.5);
360 | });
361 | });
362 | });
363 |
364 | describe('_dbgGetDocument()', () => {
365 | it('should return document or null', () => {
366 | expect(_dbgGetDocument()).toBe(document);
367 | });
368 |
369 | it('should return overrided value', () => {
370 | _dbgSetDocument(null);
371 | expect(_dbgGetDocument()).toBe(null);
372 | });
373 | });
374 |
375 | describe('_dbgSetDocument()', () => {
376 | it('should set the document or null', () => {
377 | _dbgSetDocument(null);
378 | expect(_dbgGetDocument()).toBe(null);
379 |
380 | _dbgSetDocument(document);
381 | expect(_dbgGetDocument()).toBe(document);
382 | });
383 |
384 | it('should throw if value not null or document', () => {
385 | // @ts-expect-error testing purposes
386 | expect(() => _dbgSetDocument(123)).toThrow(
387 | new TypeError(
388 | 'override value expected to be an instance of HTMLDocument or null, got number'
389 | )
390 | );
391 | // @ts-expect-error testing purposes
392 | expect(() => _dbgSetDocument(false)).toThrow(
393 | new TypeError(
394 | 'override value expected to be an instance of HTMLDocument or null, got boolean'
395 | )
396 | );
397 | });
398 | });
399 |
400 | describe('renderDivWithRenderer()', () => {
401 | let node: HTMLDivElement;
402 | beforeAll(() => {
403 | node = document.createElement('div');
404 | document.body.append(node);
405 | });
406 | afterEach(() => {
407 | ReactDOM.unmountComponentAtNode(node);
408 | });
409 | afterAll(() => {
410 | ReactDOM.unmountComponentAtNode(node);
411 | node.remove();
412 | });
413 |
414 | it('should not leak `elementRef` if renderer not presented', () => {
415 | const res = renderDivWithRenderer({ elementRef: undefined, className: 'tests' }, () => {});
416 | expect(res!.props.hasOwnProperty('elementRef')).toBe(false);
417 | expect(res!.props.className).toBe('tests');
418 | });
419 |
420 | it('should pass elementRef as ref if renderer not presented', (done) => {
421 | const ref = jasmine.createSpy();
422 | ReactDOM.render(
423 | renderDivWithRenderer({ elementRef: undefined, className: 'tests' }, ref),
424 | node,
425 | () => {
426 | expect(ref).toHaveBeenCalled();
427 | done();
428 | }
429 | );
430 | });
431 |
432 | it("should not leak renderer prop if it's passed", () => {
433 | const renderer = jasmine.createSpy();
434 | // eslint-disable-next-line unicorn/consistent-function-scoping
435 | const ref = () => {};
436 | renderDivWithRenderer({ className: 'tests', renderer }, ref);
437 |
438 | expect(renderer).toHaveBeenCalled();
439 | const res = renderer.calls.argsFor(0) as ElementPropsWithElementRef;
440 | expect(res[0].hasOwnProperty('ref')).toBe(false);
441 | expect(res[0].hasOwnProperty('renderer')).toBe(false);
442 | expect(res[0].elementRef).toBe(ref);
443 | expect(res[0].className).toBe('tests');
444 | });
445 | });
446 |
447 | describe('getScrollbarWidth', () => {
448 | beforeEach(() => {
449 | delete getScrollbarWidth._cache;
450 | _dbgSetDocument(document);
451 | });
452 |
453 | it('should return number', () => {
454 | expect(typeof getScrollbarWidth()).toBe('number');
455 | });
456 |
457 | it('should return proper number', () => {
458 | expect([17, 15].includes(getScrollbarWidth()!)).toBe(true);
459 | });
460 |
461 | it('should forced recalculate sbw if true passed as 1st parameter', () => {
462 | getScrollbarWidth._cache = 2;
463 | expect(getScrollbarWidth()).toBe(2);
464 | _dbgSetDocument(document);
465 | expect([17, 15].includes(getScrollbarWidth(true)!)).toBe(true);
466 | });
467 |
468 | it('should return 0 if document is not presented', () => {
469 | _dbgSetDocument(null);
470 | expect(getScrollbarWidth()).toBe(0);
471 | });
472 | });
473 |
474 | describe('shouldReverseRTLScroll', () => {
475 | beforeEach(() => {
476 | _dbgSetDocument(document);
477 | delete shouldReverseRtlScroll._cache;
478 | });
479 |
480 | it('should return boolean', () => {
481 | expect(typeof shouldReverseRtlScroll()).toBe('boolean');
482 | });
483 |
484 | // it("should return proper value", () => {
485 | // expect(shouldReverseRtlScroll()).toBe(true);
486 | // });
487 |
488 | // it("should forced perform check if true passed as 1st parameter", () => {
489 | // shouldReverseRtlScroll._cache = true;
490 | // expect(shouldReverseRtlScroll()).toBe(true);
491 | // expect(shouldReverseRtlScroll(true)).toBe(true);
492 | // });
493 |
494 | it('should return false if document is not presented', () => {
495 | _dbgSetDocument(null);
496 | expect(shouldReverseRtlScroll()).toBe(false);
497 | });
498 | });
499 | });
500 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "./cjs",
5 | "./coverage",
6 | "./esm",
7 | "./esnext",
8 | "./node_modules"
9 | ],
10 | "include": [
11 | ".**/*.js",
12 | "**/.*.js",
13 | "**/*.js",
14 | "**/*.ts",
15 | "**/*.tsx",
16 | "**/*.md",
17 | "**/*.mdx"
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "sourceMap": false,
4 | "removeComments": false,
5 | "noImplicitReturns": true,
6 | "strictNullChecks": true,
7 | "alwaysStrict": true,
8 | "declaration": false,
9 | "target": "es2019",
10 | "module": "commonjs",
11 | "jsx": "react",
12 | "lib": ["esnext", "dom"]
13 | },
14 | "include": ["./src/**/*.ts"],
15 | "exclude": ["node_modules"]
16 | }
17 |
--------------------------------------------------------------------------------