((el: JSX.Element, row: number) => (
16 |
17 | {Array(COLS)
18 | .fill(0)
19 | .map((el: JSX.Element, col: number) => (
20 |
21 | {row * COLS + col}
22 |
23 | ))}
24 |
25 | ));
26 |
27 | componentDidMount() {
28 | const element = this.container.current;
29 | if (element) {
30 | element.scrollTop = (element.scrollHeight - element.clientWidth) / 2;
31 | element.scrollLeft = (element.scrollWidth - element.clientHeight) / 2;
32 | }
33 | }
34 |
35 | render() {
36 | return (
37 |
38 | {
42 | console.log('onStartScroll', event);
43 | }}
44 | onScroll={(event: ScrollEvent) => {
45 | console.log('onScroll', event);
46 | }}
47 | onClick={(event: MouseEvent) => {
48 | console.log('onClick', event);
49 | }}
50 | onEndScroll={(event: ScrollEvent) => {
51 | console.log('onEndScroll', event);
52 | }}
53 | innerRef={this.container}
54 | >
55 | {this.numbers}
56 |
57 |
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/example/src/Scrollbars/style.css:
--------------------------------------------------------------------------------
1 | .scrollbars-example {
2 | border: solid 5px #E2D7C7;
3 | background: #282828;
4 | height: 310px;
5 | width: 310px;
6 | overflow: hidden;
7 | }
8 |
9 | .scrollbars-example__container {
10 | height: 100%;
11 | width: 100%;
12 | overflow: auto;
13 | }
14 |
15 | .scrollbars-example__row {
16 | display: flex;
17 | }
18 |
19 | .scrollbars-example__col {
20 | display: flex;
21 | align-items: center;
22 | justify-content: center;
23 | background: #282828;
24 | color: #E2D7C7;
25 | width: 50px;
26 | height: 50px;
27 | flex-shrink: 0;
28 | user-select: none;
29 | font-weight: bold;
30 | font-size: 25px;
31 | }
32 |
--------------------------------------------------------------------------------
/example/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 |
4 | import './style.css'
5 | import App from './App'
6 |
7 | ReactDOM.render(, document.getElementById('root'))
8 |
--------------------------------------------------------------------------------
/example/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/example/src/style.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | font-family: sans-serif;
9 | display: flex;
10 |
11 | align-items: center;
12 | justify-content: center;
13 | }
14 |
15 | body, html {
16 | height: 100%;
17 | width: 100%;
18 | }
19 |
20 | .example-application__container {
21 | margin-bottom: 40px;
22 | }
23 |
24 | .example-application__pages {
25 | text-align: center;
26 | opacity: 0.25;
27 | transition: 0.5s;
28 | }
29 |
30 | .example-application__pages:hover {
31 | opacity: 1;
32 | }
33 |
34 | .example-application__page {
35 | cursor: pointer;
36 | font-weight: bold;
37 | opacity: 0.4;
38 | transition: 0.5s;
39 | padding: 10px;
40 | }
41 |
42 | .example-application__page--active {
43 | opacity: 1;
44 | }
45 |
46 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "preserve"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-indiana-drag-scroll",
3 | "version": "2.2.1",
4 | "description": "Implements scroll on drag",
5 | "author": "Norserium",
6 | "license": "MIT",
7 | "repository": "norserium/react-indiana-drag-scroll",
8 | "main": "dist/index.js",
9 | "module": "dist/index.es.js",
10 | "jsnext:main": "dist/index.es.js",
11 | "types": "types/index.d.ts",
12 | "keywords": [
13 | "react",
14 | "drag",
15 | "scroll",
16 | "library",
17 | "lightweight",
18 | "scrolling",
19 | "dragandscroll"
20 | ],
21 | "engines": {
22 | "node": ">=8",
23 | "npm": ">=5"
24 | },
25 | "scripts": {
26 | "test": "cross-env CI=1 react-scripts test --env=jsdom",
27 | "test:watch": "react-scripts test --env=jsdom",
28 | "build": "rollup -c",
29 | "start": "rollup -c -w",
30 | "prepare": "npm run build",
31 | "predeploy": "cd example && npm install && npm run build",
32 | "deploy": "gh-pages -d example/build"
33 | },
34 | "peerDependencies": {
35 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
36 | "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
37 | },
38 | "devDependencies": {
39 | "@rollup/plugin-babel": "^5.0.4",
40 | "@rollup/plugin-commonjs": "^13.0.0",
41 | "@rollup/plugin-node-resolve": "^9.0.0",
42 | "@rollup/plugin-replace": "^2.3.3",
43 | "@rollup/plugin-typescript": "^6.1.0",
44 | "@rollup/plugin-url": "^5.0.1",
45 | "@types/react": "^16.9.19",
46 | "@typescript-eslint/eslint-plugin": "^2.34.0",
47 | "@typescript-eslint/parser": "^2.34.0",
48 | "autoprefixer": "^9.6.1",
49 | "babel-core": "^6.26.3",
50 | "babel-eslint": "^9.0.0",
51 | "babel-plugin-external-helpers": "^6.22.0",
52 | "babel-preset-env": "^1.7.0",
53 | "babel-preset-react": "^6.24.1",
54 | "babel-preset-stage-0": "^6.24.1",
55 | "cross-env": "^5.1.4",
56 | "eslint": "5.16.0",
57 | "eslint-config-prettier": "^6.15.0",
58 | "eslint-plugin-import": "^2.22.1",
59 | "eslint-plugin-prettier": "^3.3.1",
60 | "eslint-plugin-react": "^7.22.0",
61 | "gh-pages": "^1.2.0",
62 | "husky": "^5.1.1",
63 | "lint-staged": "^10.5.4",
64 | "postcss": "^8.2.6",
65 | "prettier": "^2.2.1",
66 | "react": "^19.0.0",
67 | "react-dom": "^19.0.0",
68 | "react-scripts": "^2.1.8",
69 | "rollup": "^2.26.10",
70 | "rollup-plugin-postcss": "^4.0.0",
71 | "rollup-plugin-peer-deps-external": "^2.2.3",
72 | "rollup-plugin-scss": "^2.6.1",
73 | "rollup-plugin-terser": "^7.0.2",
74 | "rollup-plugin-visualizer": "^1.1.1",
75 | "rollup-plugin-vue": "^5.1.9",
76 | "typescript": "^4.2.2"
77 | },
78 | "files": [
79 | "dist",
80 | "types/index.d.ts"
81 | ],
82 | "dependencies": {
83 | "classnames": "^2.2.6",
84 | "debounce": "^1.2.0",
85 | "easy-bem": "^1.1.1"
86 | },
87 | "husky": {
88 | "hooks": {
89 | "pre-commit": "lint-staged"
90 | }
91 | },
92 | "lint-staged": {
93 | "*.{js,ts,tsx}": [
94 | "prettier --write",
95 | "eslint"
96 | ]
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import autoprefixer from 'autoprefixer';
2 | import postcss from 'rollup-plugin-postcss';
3 | import babel from '@rollup/plugin-babel';
4 | import commonjs from '@rollup/plugin-commonjs';
5 | import replace from '@rollup/plugin-replace';
6 | import resolve from '@rollup/plugin-node-resolve';
7 | import url from '@rollup/plugin-url';
8 | import external from 'rollup-plugin-peer-deps-external';
9 | import { terser } from 'rollup-plugin-terser';
10 | import typescript from '@rollup/plugin-typescript';
11 | import pkg from './package.json';
12 |
13 | export default {
14 | input: 'src/index.tsx',
15 | output: [
16 | {
17 | file: pkg.main,
18 | format: 'cjs',
19 | sourcemap: true,
20 | },
21 | {
22 | file: pkg.module,
23 | format: 'es',
24 | sourcemap: true,
25 | },
26 | ],
27 | plugins: [
28 | external(),
29 | postcss({
30 | extensions: ['css', 'scss'],
31 | use: {
32 | sass: true,
33 | },
34 | plugins: [autoprefixer],
35 | inject: true,
36 | }),
37 | url(),
38 | babel({
39 | exclude: 'node_modules/**',
40 | plugins: ['external-helpers'],
41 | }),
42 | resolve(),
43 | commonjs(),
44 | terser(),
45 | typescript(),
46 | replace({
47 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
48 | }),
49 | ],
50 | };
51 |
--------------------------------------------------------------------------------
/src/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | CSSProperties,
3 | ElementType,
4 | MouseEvent,
5 | MutableRefObject,
6 | PureComponent,
7 | ReactNode,
8 | Ref,
9 | RefObject,
10 | } from 'react';
11 | import classnames from 'classnames';
12 | import debounce from 'debounce';
13 | import bem from 'easy-bem';
14 |
15 | import './style.scss';
16 |
17 | const cn = bem('indiana-scroll-container');
18 |
19 | const SCROLL_END_DEBOUNCE = 300;
20 |
21 | const LEFT_BUTTON = 0;
22 |
23 | export interface ScrollEvent {
24 | external: boolean;
25 | }
26 |
27 | interface Props {
28 | vertical?: boolean;
29 | horizontal?: boolean;
30 | hideScrollbars?: boolean;
31 | activationDistance?: number;
32 | children?: ReactNode;
33 | onStartScroll?: (event: ScrollEvent) => void;
34 | onScroll?: (event: ScrollEvent) => void;
35 | onEndScroll?: (event: ScrollEvent) => void;
36 | onClick?: (event: MouseEvent) => void;
37 | className?: string;
38 | draggingClassName?: string;
39 | style?: CSSProperties;
40 | ignoreElements?: string;
41 | nativeMobileScroll?: boolean;
42 | ref?: ReactNode;
43 | component?: ElementType;
44 | innerRef?: Ref;
45 | stopPropagation?: boolean;
46 | buttons?: number[];
47 | }
48 |
49 | export default class ScrollContainer extends PureComponent {
50 | static defaultProps = {
51 | nativeMobileScroll: true,
52 | hideScrollbars: true,
53 | activationDistance: 10,
54 | vertical: true,
55 | horizontal: true,
56 | stopPropagation: false,
57 | style: {},
58 | component: 'div',
59 | buttons: [LEFT_BUTTON],
60 | };
61 | container: RefObject;
62 | scrolling: boolean;
63 | started: boolean;
64 | pressed: boolean;
65 | isMobile: boolean;
66 | internal: boolean;
67 |
68 | scrollLeft?: number;
69 | scrollTop?: number;
70 | clientX?: number;
71 | clientY?: number;
72 |
73 | constructor(props) {
74 | super(props);
75 | this.container = React.createRef();
76 | this.onEndScroll = debounce(this.onEndScroll, SCROLL_END_DEBOUNCE);
77 |
78 | // Is container scrolling now (for example by inertia)
79 | this.scrolling = false;
80 | // Is scrolling started
81 | this.started = false;
82 | // Is touch active or mouse pressed down
83 | this.pressed = false;
84 | // Is event internal
85 | this.internal = false;
86 |
87 | // Bind callbacks
88 | this.getRef = this.getRef.bind(this);
89 | }
90 |
91 | componentDidMount() {
92 | const { nativeMobileScroll } = this.props;
93 | const container = this.container.current;
94 |
95 | window.addEventListener('mouseup', this.onMouseUp);
96 | window.addEventListener('mousemove', this.onMouseMove);
97 | window.addEventListener('touchmove', this.onTouchMove, { passive: false });
98 | window.addEventListener('touchend', this.onTouchEnd);
99 |
100 | // due to https://github.com/facebook/react/issues/9809#issuecomment-414072263
101 | container.addEventListener('touchstart', this.onTouchStart, {
102 | passive: false,
103 | });
104 | container.addEventListener('mousedown', this.onMouseDown, {
105 | passive: false,
106 | });
107 |
108 | if (nativeMobileScroll) {
109 | // We should check if it's the mobile device after page was loaded
110 | // to prevent breaking SSR
111 | this.isMobile = this.isMobileDevice();
112 |
113 | // If it's the mobile device, we should rerender to change styles
114 | if (this.isMobile) {
115 | this.forceUpdate();
116 | }
117 | }
118 | }
119 |
120 | componentWillUnmount() {
121 | window.removeEventListener('mouseup', this.onMouseUp);
122 | window.removeEventListener('mousemove', this.onMouseMove);
123 | window.removeEventListener('touchmove', this.onTouchMove);
124 | window.removeEventListener('touchend', this.onTouchEnd);
125 | }
126 |
127 | getElement() {
128 | return this.container.current;
129 | }
130 |
131 | isMobileDevice() {
132 | return typeof window.orientation !== 'undefined' || navigator.userAgent.indexOf('IEMobile') !== -1;
133 | }
134 |
135 | isDraggable(target) {
136 | const ignoreElements = this.props.ignoreElements;
137 | if (ignoreElements) {
138 | const closest = target.closest(ignoreElements);
139 | return closest === null || closest.contains(this.getElement());
140 | } else {
141 | return true;
142 | }
143 | }
144 |
145 | isScrollable() {
146 | const container = this.container.current;
147 | return (
148 | container &&
149 | (container.scrollWidth > container.clientWidth || container.scrollHeight > container.clientHeight)
150 | );
151 | }
152 |
153 | // Simulate 'onEndScroll' event that fires when scrolling is stopped
154 | onEndScroll = () => {
155 | this.scrolling = false;
156 | if (!this.pressed && this.started) {
157 | this.processEnd();
158 | }
159 | };
160 |
161 | onScroll = (e) => {
162 | const container = this.container.current;
163 | // Ignore the internal scrolls
164 | if (container.scrollLeft !== this.scrollLeft || container.scrollTop !== this.scrollTop) {
165 | this.scrolling = true;
166 | this.processScroll(e);
167 | this.onEndScroll();
168 | }
169 | };
170 |
171 | onTouchStart = (e) => {
172 | const { nativeMobileScroll } = this.props;
173 | if (this.isDraggable(e.target)) {
174 | this.internal = true;
175 | if (nativeMobileScroll && this.scrolling) {
176 | this.pressed = true;
177 | } else {
178 | const touch = e.touches[0];
179 | this.processClick(e, touch.clientX, touch.clientY);
180 | if (!nativeMobileScroll && this.props.stopPropagation) {
181 | e.stopPropagation();
182 | }
183 | }
184 | }
185 | };
186 |
187 | onTouchEnd = (e) => {
188 | const { nativeMobileScroll } = this.props;
189 | if (this.pressed) {
190 | if (this.started && (!this.scrolling || !nativeMobileScroll)) {
191 | this.processEnd();
192 | } else {
193 | this.pressed = false;
194 | }
195 | this.forceUpdate();
196 | }
197 | };
198 |
199 | onTouchMove = (e) => {
200 | const { nativeMobileScroll } = this.props;
201 | if (this.pressed && (!nativeMobileScroll || !this.isMobile)) {
202 | const touch = e.touches[0];
203 | if (touch) {
204 | this.processMove(e, touch.clientX, touch.clientY);
205 | }
206 | e.preventDefault();
207 | if (this.props.stopPropagation) {
208 | e.stopPropagation();
209 | }
210 | }
211 | };
212 |
213 | onMouseDown = (e) => {
214 | if (this.isDraggable(e.target) && this.isScrollable()) {
215 | this.internal = true;
216 | if (this.props.buttons.indexOf(e.button) !== -1) {
217 | this.processClick(e, e.clientX, e.clientY);
218 | e.preventDefault();
219 | if (this.props.stopPropagation) {
220 | e.stopPropagation();
221 | }
222 | }
223 | }
224 | };
225 |
226 | onMouseMove = (e) => {
227 | if (this.pressed) {
228 | this.processMove(e, e.clientX, e.clientY);
229 | e.preventDefault();
230 | if (this.props.stopPropagation) {
231 | e.stopPropagation();
232 | }
233 | }
234 | };
235 |
236 | onMouseUp = (e) => {
237 | if (this.pressed) {
238 | if (this.started) {
239 | this.processEnd();
240 | } else {
241 | this.internal = false;
242 | this.pressed = false;
243 | this.forceUpdate();
244 | if (this.props.onClick) {
245 | this.props.onClick(e);
246 | }
247 | }
248 | e.preventDefault();
249 | if (this.props.stopPropagation) {
250 | e.stopPropagation();
251 | }
252 | }
253 | };
254 |
255 | processClick(e, clientX, clientY) {
256 | const container = this.container.current;
257 | this.scrollLeft = container.scrollLeft;
258 | this.scrollTop = container.scrollTop;
259 | this.clientX = clientX;
260 | this.clientY = clientY;
261 | this.pressed = true;
262 | }
263 |
264 | processStart(changeCursor = true) {
265 | const { onStartScroll } = this.props;
266 |
267 | this.started = true;
268 |
269 | // Add the class to change displayed cursor
270 | if (changeCursor) {
271 | document.body.classList.add('indiana-dragging');
272 | }
273 |
274 | if (onStartScroll) {
275 | onStartScroll({
276 | external: !this.internal,
277 | });
278 | }
279 | this.forceUpdate();
280 | }
281 |
282 | // Process native scroll (scrollbar, mobile scroll)
283 | processScroll(e) {
284 | if (this.started) {
285 | const { onScroll } = this.props;
286 | if (onScroll) {
287 | onScroll({
288 | external: !this.internal,
289 | });
290 | }
291 | } else {
292 | this.processStart(false);
293 | }
294 | }
295 |
296 | // Process non-native scroll
297 | processMove(e, newClientX, newClientY) {
298 | const { horizontal, vertical, activationDistance, onScroll } = this.props;
299 | const container = this.container.current;
300 |
301 | if (!this.started) {
302 | if (
303 | (horizontal && Math.abs(newClientX - this.clientX) > activationDistance) ||
304 | (vertical && Math.abs(newClientY - this.clientY) > activationDistance)
305 | ) {
306 | this.clientX = newClientX;
307 | this.clientY = newClientY;
308 | this.processStart();
309 | }
310 | } else {
311 | if (horizontal) {
312 | container.scrollLeft -= newClientX - this.clientX;
313 | }
314 | if (vertical) {
315 | container.scrollTop -= newClientY - this.clientY;
316 | }
317 | if (onScroll) {
318 | onScroll({ external: !this.internal });
319 | }
320 | this.clientX = newClientX;
321 | this.clientY = newClientY;
322 | this.scrollLeft = container.scrollLeft;
323 | this.scrollTop = container.scrollTop;
324 | }
325 | }
326 |
327 | processEnd() {
328 | const { onEndScroll } = this.props;
329 | const container = this.container.current;
330 |
331 | if (container && onEndScroll) {
332 | onEndScroll({
333 | external: !this.internal,
334 | });
335 | }
336 |
337 | this.pressed = false;
338 | this.started = false;
339 | this.scrolling = false;
340 | this.internal = false;
341 |
342 | document.body.classList.remove('indiana-dragging');
343 | this.forceUpdate();
344 | }
345 |
346 | getRef(el) {
347 | [this.container, this.props.innerRef].forEach((ref) => {
348 | if (ref) {
349 | if (typeof ref === 'function') {
350 | ref(el);
351 | } else {
352 | (ref as MutableRefObject).current = el;
353 | }
354 | }
355 | });
356 | }
357 |
358 | render() {
359 | const { children, draggingClassName, className, style, hideScrollbars, component: Component } = this.props;
360 |
361 | return (
362 |
376 | {children}
377 |
378 | );
379 | }
380 | }
381 |
--------------------------------------------------------------------------------
/src/style.scss:
--------------------------------------------------------------------------------
1 | .indiana-scroll-container {
2 | overflow: auto;
3 | &--dragging {
4 | scroll-behavior: auto !important;
5 | > * {
6 | pointer-events: none;
7 | cursor: grab;
8 | }
9 | }
10 | &--hide-scrollbars {
11 | overflow: hidden;
12 | overflow: -moz-scrollbars-none;
13 | -ms-overflow-style: none;
14 | scrollbar-width: none;
15 | &::-webkit-scrollbar {
16 | display: none !important;
17 | height: 0 !important;
18 | width: 0 !important;
19 | background: transparent !important;
20 | -webkit-appearance: none !important;
21 | }
22 | }
23 | &--native-scroll {
24 | overflow: auto;
25 | }
26 | }
27 |
28 | .indiana-dragging {
29 | cursor: grab;
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "es2015"
8 | ],
9 | "noEmitHelpers": true,
10 | "importHelpers": true,
11 | "allowJs": true,
12 | "allowSyntheticDefaultImports": true,
13 | "esModuleInterop": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "isolatedModules": true,
16 | "jsx": "react",
17 | "module": "esnext",
18 | "moduleResolution": "node",
19 | "noEmit": true,
20 | "resolveJsonModule": true,
21 | "skipLibCheck": true
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { Ref, MouseEvent, Component, CSSProperties, ReactNode, ElementType } from 'react';
2 |
3 | export interface ScrollEvent {
4 | external: boolean;
5 | }
6 |
7 | export interface ScrollContainerProps {
8 | vertical?: boolean;
9 | horizontal?: boolean;
10 | hideScrollbars?: boolean;
11 | activationDistance?: number;
12 | children?: ReactNode;
13 | onStartScroll?: (event: ScrollEvent) => void;
14 | onScroll?: (event: ScrollEvent) => void;
15 | onEndScroll?: (event: ScrollEvent) => void;
16 | onClick?: (event: MouseEvent) => void;
17 | className?: string;
18 | draggingClassName?: string;
19 | style?: CSSProperties;
20 | ignoreElements?: string;
21 | nativeMobileScroll?: boolean;
22 | ref?: ReactNode;
23 | component?: ElementType;
24 | innerRef?: Ref;
25 | stopPropagation?: boolean;
26 | buttons?: number[];
27 | }
28 |
29 | export default class ScrollContainer extends Component {
30 | getElement: () => HTMLElement;
31 | }
32 |
--------------------------------------------------------------------------------