├── .nvmrc ├── example ├── assets │ └── .gitkeep ├── cat.gif ├── styled.ts ├── index.tsx ├── index.html ├── utils.ts └── app.tsx ├── .gitignore ├── .npmignore ├── .travis.yml ├── src ├── StrollCaptor.tsx ├── types.ts ├── index.ts ├── context.tsx ├── StrollableContainer.tsx ├── utils.ts ├── Container.tsx ├── DragEngine.ts ├── Bar.tsx └── Stroller.tsx ├── tsconfig.json ├── LICENSE ├── __tests__ ├── index.tsx └── __snapshots__ │ └── index.tsx.snap ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.5.0 -------------------------------------------------------------------------------- /example/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /dist/ 3 | .DS_Store 4 | coverage/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | .DS_Store 4 | example 5 | __tests__ -------------------------------------------------------------------------------- /example/cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theKashey/React-stroller/HEAD/example/cat.gif -------------------------------------------------------------------------------- /example/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const AppWrapper = styled.div` 4 | 5 | `; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | cache: yarn 5 | script: 6 | - yarn 7 | - yarn test:ci 8 | notifications: 9 | email: false -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './app'; 4 | 5 | ReactDOM.render(, document.getElementById('app')); 6 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/StrollCaptor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {StrollerContext} from "./context"; 3 | 4 | export const StrollCaptor = () => ( 5 | 6 | {({setScrollContainer}) => ( 7 |
setScrollContainer(ref)}/> 8 | )} 9 | 10 | ); -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {BarLocation} from "./Bar"; 2 | 3 | export interface IStrollerState { 4 | scrollWidth: number; 5 | scrollHeight: number; 6 | 7 | clientWidth: number; 8 | clientHeight: number; 9 | 10 | scrollLeft: number; 11 | scrollTop: number; 12 | 13 | dragPhase: string; 14 | mousePosition: number[]; 15 | scrollPosition: number[]; 16 | 17 | barLocation: BarLocation; 18 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Strollable} from "./Container"; 2 | import {Stroller} from './Stroller'; 3 | import {StrollableContainer} from "./StrollableContainer"; 4 | import {StrollCaptor} from './StrollCaptor'; 5 | import {StrollerState} from "./context"; 6 | import {getScrollBarWidth} from "./utils"; 7 | 8 | export { 9 | Strollable, 10 | Stroller, 11 | StrollableContainer, 12 | StrollCaptor, 13 | 14 | StrollerState, 15 | getScrollBarWidth 16 | } -------------------------------------------------------------------------------- /example/utils.ts: -------------------------------------------------------------------------------- 1 | import {Component} from 'react'; 2 | 3 | export class ToolboxApp extends Component { 4 | onCheckboxChange = (propName: any) => () => { 5 | const currentValue = (this.state as any)[propName]; 6 | this.setState({ [propName]: !currentValue } as any); 7 | } 8 | 9 | onFieldTextChange = (propName: any) => (e: any) => { 10 | const value = e.target.value; 11 | 12 | (this as any).setState({ 13 | [propName]: value 14 | }); 15 | } 16 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictNullChecks": true, 5 | "strictFunctionTypes": true, 6 | "skipLibCheck": true, 7 | "noImplicitThis": true, 8 | "alwaysStrict": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": true, 14 | "target": "es5", 15 | "lib": [ 16 | "dom", 17 | "es5", 18 | "scripthost", 19 | "es2015.collection", 20 | "es2015.symbol", 21 | "es2015.iterable", 22 | "es2015.promise" 23 | ], 24 | "jsx": "react" 25 | } 26 | } -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {IStrollerState} from "./types"; 3 | 4 | export interface IStrollerContext { 5 | setScrollContainer: (ref: HTMLElement | null) => any; 6 | } 7 | 8 | const contextDefault:IStrollerContext = { 9 | setScrollContainer: () => { throw new Error('StrollerCaptor used without Stroller')} 10 | }; 11 | 12 | export const context = React.createContext(contextDefault); 13 | export const StrollerProvider = context.Provider; 14 | export const StrollerContext = context.Consumer; 15 | 16 | export const stateContext = React.createContext({} as IStrollerState); 17 | export const StrollerStateProvider = stateContext.Provider; 18 | export const StrollerState = stateContext.Consumer; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Anton 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 | -------------------------------------------------------------------------------- /__tests__/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render, fireEvent } from "@testing-library/react"; 3 | import { Strollable, StrollableContainer } from "../src"; 4 | import {findScrollableParent} from "../src/utils"; 5 | 6 | describe("Strollable", () => { 7 | it("should render correctly", () => { 8 | const { container } = render(); 9 | 10 | expect(container).toMatchSnapshot(); 11 | }); 12 | }); 13 | 14 | 15 | describe("StrollableContainer", () => { 16 | it("should render correctly", () => { 17 | const { container } = render( 18 | child 19 | ); 20 | 21 | expect(container).toMatchSnapshot(); 22 | }); 23 | 24 | it("should handle scroll", () => { 25 | const mockOnScroll = jest.fn(); 26 | const { getByTestId } = render( 27 |
inner
28 | ); 29 | 30 | expect(mockOnScroll).toHaveBeenCalledTimes(0); 31 | 32 | const child = getByTestId('child'); 33 | fireEvent.scroll(findScrollableParent(child), { target: { scrollY: 100 } }) 34 | 35 | expect(mockOnScroll).toHaveBeenCalledTimes(1); 36 | }) 37 | }); 38 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/index.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Strollable should render correctly 1`] = ` 4 |
5 |
8 |
11 |
14 |
15 |
16 |
17 | `; 18 | 19 | exports[`StrollableContainer should render correctly 1`] = ` 20 |
21 |
24 |
27 |
30 |
33 |
36 |
37 | child 38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | `; 46 | -------------------------------------------------------------------------------- /src/StrollableContainer.tsx: -------------------------------------------------------------------------------- 1 | import {IContainerProps, Strollable, strollerStyle} from "./Container"; 2 | import {Stroller, IStrollerProps} from "./Stroller"; 3 | import * as React from "react"; 4 | import {StrollCaptor} from "./StrollCaptor"; 5 | 6 | export type Props = IContainerProps & IStrollerProps; 7 | 8 | export const StrollableContainer: React.SFC = ( 9 | { 10 | children, 11 | className, 12 | 13 | axis, 14 | bar, 15 | inBetween, 16 | 17 | scrollBar, 18 | oppositePosition, 19 | draggable, 20 | 21 | barSizeFunction, 22 | barClassName, 23 | SideBar, 24 | 25 | overrideLocation, 26 | targetAxis, 27 | 28 | overscroll, 29 | containerStyles, 30 | minScrollbarWidth, 31 | 32 | scrollKey, 33 | gap, 34 | 35 | onScroll 36 | } 37 | ) => ( 38 |
39 | 60 | 68 | 69 | {children} 70 | 71 | 72 |
73 | ) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-stroller", 3 | "version": "1.8.1", 4 | "description": "React custom scrollbars, I mean strollbars", 5 | "scripts": { 6 | "test": "ts-react-toolbox test", 7 | "bootstrap": "ts-react-toolbox init", 8 | "dev": "ts-react-toolbox dev", 9 | "test:ci": "ts-react-toolbox test --runInBand --coverage", 10 | "test:size": "size-limit", 11 | "build": "ts-react-toolbox build", 12 | "prepublish": "ts-react-toolbox build", 13 | "release": "ts-react-toolbox release", 14 | "lint": "ts-react-toolbox lint", 15 | "static": "ts-react-toolbox publish", 16 | "format": "ts-react-toolbox format", 17 | "analyze": "ts-react-toolbox analyze" 18 | }, 19 | "devDependencies": { 20 | "@size-limit/preset-small-lib": "^4.10.1", 21 | "@testing-library/react": "^11.2.5", 22 | "prettier": "^2.2.1", 23 | "react": "^16.8.6", 24 | "react-dom": "^16.8.6", 25 | "size-limit": "^4.10.1", 26 | "styled-components": "^5.2.1", 27 | "ts-react-toolbox": "^1.1.1" 28 | }, 29 | "engines": { 30 | "node": ">=10.24.0" 31 | }, 32 | "peerDependencies": { 33 | "react": "^16.3.0 || ^17.0.0 || ^18.0.0 " 34 | }, 35 | "files": [ 36 | "dist" 37 | ], 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/theKashey/react-stroller.git" 41 | }, 42 | "author": "theKashey ", 43 | "license": "MIT", 44 | "keywords": [ 45 | "react", 46 | "scroll", 47 | "scrollbar", 48 | "custom scrollbar" 49 | ], 50 | "dependencies": { 51 | "detect-passive-events": "^2.0.3", 52 | "faste": "^1.0.3", 53 | "tslib": "^2.1.0" 54 | }, 55 | "types": "dist/es5/index.d.ts", 56 | "jsnext:main": "dist/es2015/index.js", 57 | "module": "dist/es2015/index.js", 58 | "main": "dist/es5/index.js", 59 | "size-limit": [ 60 | { 61 | "path": "dist/es2015/index.js", 62 | "limit": "5.50 KB" 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const axisToOverflow = { 2 | vertical: 'overflowY', 3 | horizontal: 'overflowX' 4 | }; 5 | 6 | export const axisToOverflowReverse = { 7 | vertical: 'overflowX', 8 | horizontal: 'overflowY' 9 | }; 10 | 11 | export const axisToAxis = { 12 | vertical: 'Y', 13 | horizontal: 'X' 14 | } 15 | 16 | export type axisTypes = 'vertical' | 'horizontal'; 17 | 18 | export const axisToProps = { 19 | 'vertical': { 20 | scroll: 'scrollTop', 21 | space: 'clientHeight', 22 | targetSpace: 'targetHeight', 23 | scrollSpace: 'scrollHeight', 24 | start: 'top', 25 | end: 'bottom', 26 | 27 | coord: 1, 28 | }, 29 | 'horizontal': { 30 | scroll: 'scrollLeft', 31 | space: 'clientWidth', 32 | targetSpace: 'targetWidth', 33 | scrollSpace: 'scrollWidth', 34 | start: 'left', 35 | end: 'right', 36 | 37 | coord: 0, 38 | } 39 | }; 40 | 41 | export const findScrollableParent = (node: HTMLElement, axis: axisTypes = 'vertical'): HTMLElement => { 42 | if (node === document.body) { 43 | return node; 44 | } 45 | const style = window.getComputedStyle(node); 46 | const flow: string = style[axisToOverflow[axis] as any]; 47 | if (flow === 'hidden' || flow === 'scroll') { 48 | return node; 49 | } 50 | return node.parentNode 51 | ? findScrollableParent(node.parentNode as any, axis) 52 | : node; 53 | }; 54 | 55 | let scrollbarWidth = -1; 56 | 57 | export const getScrollBarWidth = (): number => { 58 | if(typeof document === 'undefined'){ 59 | return 24; 60 | } 61 | if (scrollbarWidth < 0) { 62 | const outer = document.createElement('div'); 63 | const inner = document.createElement('div'); 64 | outer.style.overflow = 'scroll'; 65 | document.body.appendChild(outer); 66 | outer.appendChild(inner); 67 | scrollbarWidth = outer.offsetWidth - inner.offsetWidth; 68 | document.body.removeChild(outer); 69 | } 70 | return scrollbarWidth; 71 | } 72 | 73 | export const extractValues = (set: any, axis: axisTypes) => { 74 | const ax = axisToProps[axis]; 75 | const scrollSpace: number = set[ax.scrollSpace]; 76 | const space: number = set[ax.space]; 77 | const targetSpace: number = set[ax.targetSpace]; 78 | const scroll: number = set[ax.scroll]; 79 | 80 | return { 81 | scrollSpace, 82 | space, 83 | targetSpace, 84 | scroll 85 | }; 86 | } -------------------------------------------------------------------------------- /src/Container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {axisToOverflow, axisTypes, getScrollBarWidth} from "./utils"; 3 | 4 | export interface IContainerProps { 5 | axis?: axisTypes; 6 | className?: string; 7 | overscroll?: boolean; 8 | gap?: number; 9 | minScrollbarWidth?: number; 10 | containerStyles?: CSSStyleDeclaration; 11 | } 12 | 13 | const getStyle = (scrollWidth: number, gap: number, overscroll: boolean, axis: axisTypes = 'vertical'): React.CSSProperties => { 14 | return { 15 | width: axis === 'vertical' ? `calc(100% + ${scrollWidth - gap}px)` : '100%', 16 | height: axis !== 'vertical' ? `calc(100% + ${scrollWidth - gap}px)` : '100%', 17 | // width:'100%', 18 | // height:'100%', 19 | maxWidth: 'inherit', 20 | maxHeight: 'inherit', 21 | position: 'relative', 22 | [axisToOverflow[axis]]: 'scroll', 23 | overscrollBehavior: overscroll ? 'contain' : 'inherit', 24 | [axis === 'vertical' ? 'paddingRight' : 'paddingBottom']: (scrollWidth + 24) + 'px', 25 | boxSizing: "content-box", 26 | } 27 | }; 28 | 29 | const containerStyle: React.CSSProperties = { 30 | height: '100%', 31 | width: '100%', 32 | overflow: 'hidden', 33 | maxWidth: 'inherit', 34 | maxHeight: 'inherit', 35 | }; 36 | 37 | export const strollerStyle: React.CSSProperties = { 38 | height: '100%', 39 | width: '100%', 40 | maxWidth: 'inherit', 41 | maxHeight: 'inherit', 42 | // display: 'inline-block', // WHY? 43 | }; 44 | 45 | export const subcontainerStyle: React.CSSProperties = { 46 | // minHeight: '100%', // an issue for windows 47 | // minWidth: '100%', 48 | width: '100%', 49 | height: '100%', 50 | // maxWidth: 'inherit', 51 | // maxHeight: 'inherit', 52 | position: 'relative', 53 | // display: 'inline-block', // WHY? 54 | }; 55 | 56 | export class Strollable extends React.Component { 57 | scrollWidth = getScrollBarWidth(); 58 | 59 | render() { 60 | const {children, axis, overscroll = false, className, gap = 0, minScrollbarWidth = 0, containerStyles = {}} = this.props; 61 | return ( 62 |
63 |
64 |
65 | {children} 66 |
67 |
68 |
69 | ); 70 | } 71 | } -------------------------------------------------------------------------------- /src/DragEngine.ts: -------------------------------------------------------------------------------- 1 | import {AnyHookCallback, faste, HookCallback, Faste, InternalMachine, MessageHandler} from 'faste'; 2 | 3 | export { 4 | Faste, 5 | InternalMachine, 6 | MessageHandler 7 | }; 8 | 9 | const nodeHook: AnyHookCallback = { 10 | on: ({attrs, trigger, message}) => { 11 | const hook = (event: any) => { 12 | trigger(message, event); 13 | event.preventDefault(); 14 | }; 15 | attrs.node.addEventListener(message, hook); 16 | return [attrs.node, hook] 17 | }, 18 | off: ({message}, [node, hook]) => { 19 | node.removeEventListener(message, hook) 20 | } 21 | }; 22 | 23 | const documentHook: HookCallback = { 24 | on: ({trigger, message}) => { 25 | const hook = (event: any) => { 26 | trigger(message, event); 27 | event.preventDefault(); 28 | }; 29 | document.addEventListener(message, hook, true); 30 | return hook 31 | }, 32 | off: ({message}, hook) => { 33 | document.removeEventListener(message, hook, true) 34 | } 35 | }; 36 | 37 | const getCoords = (event: MouseEvent | Touch) => [event.clientX, event.clientY]; 38 | 39 | export const DragMachine = faste() 40 | .withPhases(['init', 'disabled', 'idle', 'dragging', 'cancelDrag']) 41 | .withAttrs<{ node?: HTMLElement, enabled?: boolean }>({}) 42 | .withMessages(['check', 'down', 'up', 'move', 'mousedown', 'mouseup', 'mousemove', 'touchstart', 'touchmove', 'touchend']) 43 | .withSignals(['up', 'down', 'move']) 44 | 45 | .on('check', ['init', 'disabled'], ({attrs, transitTo}) => attrs.enabled && attrs.node && transitTo('idle')) 46 | .on('check', ['idle', 'dragging'], ({attrs, transitTo}) => (!attrs.enabled || !attrs.node) && transitTo('disabled')) 47 | 48 | // outer reactions 49 | .on('down', ({transitTo, emit}, event) => { 50 | emit('down', event); 51 | transitTo('dragging') 52 | }) 53 | .on('up', ({transitTo}) => transitTo('idle')) 54 | .on('move', ({emit}, event) => emit('move', event)) 55 | .on('@enter', ['cancelDrag'], ({transitTo}) => transitTo('idle')) 56 | 57 | // mouse events 58 | .on('mousedown', ['idle'], ({trigger}, event: MouseEvent) => trigger('down', getCoords(event))) 59 | .on('mouseup', ['dragging'], ({trigger}) => trigger('up')) 60 | .on('mousemove', ['dragging'], ({transitTo}, event: MouseEvent) => event.buttons !== 1 && transitTo('cancelDrag')) 61 | .on('mousemove', ['dragging'], ({trigger}, event: MouseEvent) => trigger('move', getCoords(event))) 62 | 63 | // touch events 64 | .on('touchstart', ['idle'], ({trigger}, event: TouchEvent) => trigger('down', getCoords(event.touches[0]))) 65 | .on('touchend', ['dragging'], ({trigger}) => trigger('up')) 66 | .on('touchmove', ['dragging'], ({trigger}, event: TouchEvent) => trigger('move', getCoords(event.touches[0]))) 67 | 68 | .hooks({ 69 | mousedown: nodeHook, 70 | mouseup: documentHook, 71 | mousemove: documentHook, 72 | 73 | touchstart: nodeHook, 74 | touchmove: documentHook, 75 | touchend: documentHook, 76 | }); -------------------------------------------------------------------------------- /src/Bar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {axisToAxis, axisTypes} from "./utils"; 3 | 4 | export type BarView = React.ComponentType<{ dragging?: boolean, axis?: axisTypes }> | React.ComponentType; 5 | 6 | export type BarSizeFunction = (height: number, scrollHeight: number, flags: { dragging: boolean, default: number }) => number; 7 | 8 | export type BarLocation = "fixed" | "inside" | "outside"; 9 | 10 | export interface IScrollParams { 11 | scrollSpace: number; 12 | scroll: number; 13 | space: number; 14 | targetSpace: number; 15 | } 16 | 17 | export type ISideBar = React.SFC<{ styles: CSSStyleDeclaration }>; 18 | 19 | export interface IStrollerBarProps { 20 | mainScroll: IScrollParams; 21 | targetScroll?: IScrollParams; 22 | 23 | forwardRef: (ref: HTMLElement) => void; 24 | internal?: BarView; 25 | axis?: axisTypes, 26 | targetAxis?: axisTypes, 27 | oppositePosition?: boolean; 28 | draggable?: boolean; 29 | dragging?: boolean; 30 | sizeFunction?: BarSizeFunction; 31 | location: BarLocation; 32 | 33 | className?: string; 34 | SideBar?: ISideBar; 35 | } 36 | 37 | const Bar: BarView = ({axis}: { axis: axisTypes }) => ( 38 |
46 | ); 47 | 48 | const positions = { 49 | vertical: { 50 | 0: { 51 | top: 0, 52 | right: 0, 53 | }, 54 | 1: { 55 | top: 0, 56 | left: 0, 57 | } 58 | }, 59 | 60 | horizontal: { 61 | 0: { 62 | bottom: 0, 63 | left: 0, 64 | }, 65 | 1: { 66 | top: 0, 67 | left: 0, 68 | }, 69 | } 70 | }; 71 | 72 | export const defaultSizeFunction = (height: number, scrollHeight: number): number => ( 73 | height * (height / scrollHeight) 74 | ); 75 | 76 | export const StollerBar: React.SFC = ({ 77 | mainScroll, 78 | // targetScroll, 79 | SideBar, 80 | 81 | forwardRef, 82 | internal, 83 | axis = 'vertical', 84 | oppositePosition = false, 85 | draggable = false, 86 | sizeFunction = defaultSizeFunction, 87 | dragging = false, 88 | location, 89 | className 90 | }) => { 91 | if (mainScroll.scrollSpace <= mainScroll.space) { 92 | return null; 93 | } 94 | 95 | const barSize = sizeFunction(mainScroll.space, mainScroll.scrollSpace, { 96 | dragging, 97 | default: defaultSizeFunction(mainScroll.space, mainScroll.scrollSpace) 98 | }); 99 | 100 | const Internal: BarView = internal || Bar; 101 | 102 | const usableSpace = (mainScroll.scrollSpace - mainScroll.space); 103 | 104 | const endPosition = location === 'inside' 105 | ? (mainScroll.scrollSpace - barSize) 106 | : (mainScroll.targetSpace - barSize); 107 | 108 | const top = endPosition * mainScroll.scroll / usableSpace; 109 | 110 | const transform = 'translate' + (axisToAxis[axis]) + '(' + (Math.max(0, Math.min(endPosition, top))) + 'px)'; 111 | 112 | const styles = { 113 | position: location === 'fixed' ? 'fixed' : 'absolute', 114 | display: 'flex', 115 | cursor: dragging ? 'grabbing' : (draggable ? 'grab' : 'default'), 116 | 117 | [axis === 'vertical' ? 'height' : 'width']: Math.round(barSize) + 'px', 118 | 119 | ...(positions[axis][oppositePosition ? 1 : 0] as any) 120 | }; 121 | 122 | return ( 123 | 124 | {SideBar && } 125 |
135 | 136 |
137 |
138 | ); 139 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

React-Sctroller 📜🏃‍

3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | ----- 17 | The right page scroller - browser friendly custom draggable scrollbars. 18 | [Demo](https://codesandbox.io/s/mm5xq5kv5y) 19 | 20 | # Capabilities 21 | 22 | - ⛓display any custom scroll bar, even, well, [nyan-cat](https://github.com/theKashey/react-nyan-stroller)🐈🏳️‍🌈🏳️‍🌈🏳️‍🌈 scroll bar 23 | - 📜 vertical and horizontal, as well as not matching main scroll axis - like displaying horizontal "reading indicator" for the vertical scroll. 24 | - 👨‍🔬display scrollbar 1) inside 2) outside 3) at window the target scrollable 25 | - 🤓support for "ScrollIndicators", and actually any other "custom" 🤹‍♀️ effects 26 | - 🚀support for passive scroll observation (🥳 performance) 27 | - 🧸easy to use out of the box/fully customizable. 28 | 29 | # API 30 | Stroller provides 4 components - to create Scrollable `container`, to draw a `scroll bar` and 31 | to `combine` all together. The 4th component is a magic one - `StrollCaptor`. 32 | 33 | Written in TypeScript. IDE should provide 100% prop competition. 34 | 35 | Provides friction-less expirience, as long stroller does not hook into `onwheel` event, 36 | observing browser scroll silently, keeping all animations smooth. 37 | 38 | Could be used inside and outside scrollable node, autodetecting nearest scrollable parent. 39 | 40 | ```js 41 | import {StrollableContainer} from 'react-stroller'; 42 | 43 | 44 | 45 |
    46 | 47 | 48 | ``` 49 | 50 | React-stroller consists from 3 parts: 51 | - `Strollable` - "scrollable" container. It will remove native browser scroll. 52 | - `Stroller` - the main component, containing all the logic 53 | - `StrollCaptor` - component, which bypasses scrollable elements, finding the nodes to control. 54 | 55 | `StrollableContainer` - just combines them all in the right order. 56 | 57 | ### Strollable 58 | Is a scrollable, but __scroll-bar-less container__. It uses _padding-hack_ to hide browser scrollbars 59 | on any system. 60 | 61 | Read more about scroll bars here - [Scroll to the future](https://evilmartians.com/chronicles/scroll-to-the-future-modern-javascript-css-scrolling-implementations) 62 | 63 | ```js 64 | import {Strollable} from 'react-stroller'; 65 | 66 |
    67 | 68 | Strollable will consume 100% width/height - all the possible space 69 | setup `position:relative` to the child 70 | and display any content inside 71 | 72 |
    73 | ``` 74 | 75 | ### Stroller 76 | Stroller is a React-scrollbar. It observes `scroll` event, and position itself where it should be. 77 | Stroller likes to be placed inside Strollable. 78 | 79 | Meanwhile could be used to scroll "unscrollable"(unwheelable) containers. 80 | ```js 81 | import {Stroller} from 'react-stroller'; 82 | 83 |
    84 |
    Your Own scroll _bar_ implementation
    } 89 | scrollBar={() =>
    Your Own __scroll bar__ implementation
    } 90 | oppositePosition /* if you want scroll bar on left, or top */ 91 | draggable /* should it be draggable? */ 92 | barHeight={(height, scrollHeight, {dragging}) => dragging ? 42 : 24} /* you can override scroll element height */ 93 | scrollKey={any} // key to indicate that stroller should update data (scroll height) 94 | passive={true} // enable passive scroll observation. Better for perf, worse for scroll synchronization 95 | onScroll={() => {}} // handle scroll event 96 | /> 97 |
    98 | ``` 99 | Stroller will find nearest scrollable parent, and set a scroll bar. 100 | `bar`, you can override is just a view, an internal node for a _real_ Bar Stroller will 101 | draw itself. `bar` should fill 100% height and 100% width, and be just _style_. 102 | 103 | `scrollBar` property currently is not documented, and used only by [react-nyan-scroll](https://github.com/theKashey/react-nyan-stroller). 104 | 105 | ### StrollableContainer 106 | Just combine all Components together in the right order 107 | ```js 108 | import {StrollableContainer} from 'react-stroller'; 109 | 110 |
    111 | 112 | any content 113 | 114 |
    115 | ``` 116 | 117 | - __Do not set `overflow` property for StrollableContainer's parent__, as long it will 118 | set it for itself. 119 | - Always __set height__, you can change it via `flex-grow`, or `flex-shrink`, but it has to be set. 120 | 121 | ### StrollCaptor - the secret sauce 122 | By default Stroller could be not super smooth, as long it will be first "scrolled" 123 | as a part of scrollable node content, and then will be moved to a new position. 124 | 125 | It is natural to have some visual glitches and jumps, if you are not controlling wheel and emulating 126 | scroll event as any other "custom-scroll-bar" does. 127 | 128 | `StrollCaptor` is a fix - place it __inside__ scrollable node, while placing Stroller __outside__. 129 | As result - on component scroll Strolled will not be moved, removing any possible _jumps_. 130 | ```js 131 |
    132 | 133 |
    134 | // this is optional 135 | 136 | // StrollCaptor will report to Stroller about his scrollable parent 137 | // which is a child for Stroller, and invisible by default. 138 | 139 |
    140 |
    141 |
    142 | ``` 143 | 144 | ### StrollerState 145 | It's possible to create "Virtual Container", similar to `react-window`, using React-Strollable. 146 | 147 | Stroller will expose internal state via `StrollerState` component, you can _consume_ and then react 148 | to scroll or resize. 149 | ```js 150 | 151 | 152 | {({ 153 | scrollWidth: number, 154 | scrollHeight: number, 155 | 156 | clientWidth: number, 157 | clientHeight: number, 158 | 159 | scrollLeft: number, 160 | scrollTop: number, 161 | }) => ( 162 | // render elements based on visibility. 163 | )} 164 | 165 | 166 | ``` 167 | 168 | ## ScrollIndicators 169 | Just read stroller state and display them 170 | ```js 171 | import {StrollerState} from 'react-stroller'; 172 | export const VerticalScrollIndicator= () => ( 173 | 174 | {({scrollTop, scrollHeight, clientHeight}) => ( 175 | <> 176 | 182 | = scrollHeight && styles['indicator--hidden'] 186 | )} 187 | /> 188 | 189 | )} 190 | 191 | ); 192 | ``` 193 | 194 | ## Separated scrollbar 195 | You might display scrollbar in the parent, while observing scroll at the children 196 | ```js 197 | // "Bar" would be displayed on this level 198 | any code 199 | // we are "observing" scroll in this node 200 | 201 | 202 | {children} 203 | 204 | 205 | 206 | any code 207 | 208 | ``` 209 | 210 | ## Testing 211 | React-stroller is a library, which could not be unit tested. Things like smooth scroll, right overflows and 212 | touch-n-feel experience are not something robot could test. 213 | Tested manually and carefully by a human being. 214 | 215 | Uses TypeScript and a finite state machine(Faste) underneath, for a better confidence. 216 | 217 | # See also 218 | 219 | [react-custom-scrollbars](https://github.com/malte-wessel/react-custom-scrollbars) - another great package for custom scrollbars 220 | 221 | [React-Locky](https://github.com/theKashey/react-locky) - gather a full control under your scroll. 222 | 223 | [React-focus-lock](https://github.com/theKashey/react-focus-lock) - scope your focus in browser friendly way. 224 | 225 | # Licence 226 | MIT 227 | -------------------------------------------------------------------------------- /example/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import {AppWrapper} from './styled'; 4 | 5 | import {Stroller, Strollable, StrollerState, StrollCaptor, StrollableContainer} from "../src"; 6 | import {IStrollerBarProps} from "../src/Bar"; 7 | 8 | export interface AppState { 9 | 10 | } 11 | 12 | const Block = styled.div` 13 | height: 200px; 14 | width: 800px; 15 | background-color:#f0f0f0; 16 | position: relative; 17 | `; 18 | 19 | const MHBlock = styled.div` 20 | max-height: 200px; 21 | width: 800px; 22 | background-color:#f0f0f0; 23 | position: relative; 24 | `; 25 | 26 | const LongLi = styled.div` 27 | display: flex; 28 | flex-direction:row; 29 | ul { 30 | display:flex; 31 | } 32 | li { 33 | padding:5px; 34 | } 35 | `; 36 | 37 | const UL = () => ( 38 |
      39 | {(Array(20) as any) 40 | .fill(1) 41 | .map((_: any, index: number) =>
    • {(index + "xx ").repeat(50)}
    • ) 42 | } 43 |
    44 | ) 45 | 46 | const Bar = styled.div` 47 | width:8px; 48 | height:8px; 49 | border-radius:8px; 50 | align-self: center; 51 | background-color: #F00; 52 | 53 | transition-property: transform; 54 | transition-duration: 300ms; 55 | 56 | transform-origin: 100% 50%; 57 | 58 | &:hover { 59 | transform: scale(1.5); 60 | } 61 | 62 | ${(props: any) => props.dragging && ` 63 | && {transform: scale(2);} 64 | `} 65 | `; 66 | 67 | const positions = { 68 | vertical: { 69 | 0: { 70 | top: 0, 71 | right: 0, 72 | }, 73 | 1: { 74 | top: 0, 75 | left: 0, 76 | } 77 | }, 78 | 79 | horizontal: { 80 | 0: { 81 | bottom: 0, 82 | left: 0, 83 | }, 84 | 1: { 85 | top: 0, 86 | left: 0, 87 | }, 88 | } 89 | }; 90 | 91 | const NuanCarBar: React.SFC = ({ 92 | mainScroll, 93 | targetScroll = { scrollSpace: 10, space: 6 }, 94 | forwardRef, 95 | location, 96 | dragging, 97 | draggable, 98 | oppositePosition, 99 | targetAxis = 'vertical' 100 | }) => { 101 | const factor = mainScroll.scroll / (mainScroll.scrollSpace - mainScroll.space); 102 | const length = 103 | location === 'inside' 104 | ? (targetScroll.scrollSpace) * factor 105 | : (targetScroll.space - (targetAxis === 'horizontal' ? 26 : 0)) * factor; 106 | 107 | const W = targetAxis === 'horizontal' ? 'width' : 'height'; 108 | const H = targetAxis !== 'horizontal' ? 'width' : 'height'; 109 | return ( 110 |
    122 |
    131 |
    138 | 139 |
    152 |
    153 |
    154 | ); 155 | } 156 | 157 | const NyanBarFixed = () => ( 158 | 159 | ) 160 | 161 | const ScrollIndicator = () => ( 162 | 163 | {({scrollTop, scrollHeight, clientHeight}) => ( 164 | 165 |
    177 | top {scrollTop} {scrollHeight} 178 |
    179 |
    191 | bottom 192 |
    193 |
    194 | )} 195 |
    196 | ); 197 | 198 | export default class App extends React.Component <{}, AppState> { 199 | state: AppState = {} 200 | 201 | render() { 202 | return ( 203 | 204 | max-height 205 | 206 | }> 207 |
      208 | 209 | 210 | 211 | 212 | Simple 213 | 214 | 215 |
        216 |
        217 | {/*
          */} 218 | 219 |
        220 | 221 | 222 |
        223 | {1 &&
        224 | Outer 225 | 226 | 227 | 228 | 229 |
          230 |
          231 | {/*
            */} 232 | 233 |
          234 | 235 | 236 | 237 | 238 |
          239 | Container 240 | 241 | 242 |
            243 | 244 | 245 |
            246 | Container + GAP 247 | 248 | 249 |
              250 | 251 | 252 |
              253 | Nyan Container 254 | 255 | 256 |
                257 | 258 | 259 |
                260 | 261 | Draggable 262 | 263 | 264 |
                  265 |
                  266 | {/*
                    */} 267 | 268 |
                  269 | 270 | 271 |
                  272 | Custom Bar 273 | 274 | 275 |
                    276 |
                    277 | {/*
                      */} 278 | dragging ? 16 : 8} 282 | draggable 283 | /> 284 |
                    285 | 286 | 287 |
                    288 | In hidden block 289 | 290 |
                    291 |
                      292 |
                      293 | {/*
                        */} 294 | 295 |
                      296 |
                    297 |
                    298 |
                    299 | Horizontal 300 | 301 | 302 | 303 |
                      304 |
                        305 |
                          306 | 307 | 308 | 309 |
                          310 |
        311 | } 312 | 313 | 314 | 315 | ) 316 | } 317 | } -------------------------------------------------------------------------------- /src/Stroller.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {supportsPassiveEvents} from 'detect-passive-events'; 3 | 4 | import {axisToProps, axisTypes, extractValues, findScrollableParent} from "./utils"; 5 | import {BarLocation, BarSizeFunction, BarView, StollerBar, IStrollerBarProps, ISideBar} from "./Bar"; 6 | import {DragMachine} from "./DragEngine"; 7 | 8 | import {StrollerProvider, StrollerStateProvider} from './context'; 9 | import {strollerStyle} from "./Container"; 10 | 11 | export interface IStrollerProps { 12 | axis?: axisTypes; 13 | targetAxis?: axisTypes; 14 | 15 | inBetween?: React.ReactNode; 16 | bar?: BarView, 17 | scrollBar?: React.ComponentType; 18 | barSizeFunction?: BarSizeFunction; 19 | barClassName?: string, 20 | SideBar?: ISideBar; 21 | 22 | oppositePosition?: boolean, 23 | draggable?: boolean 24 | 25 | overrideLocation?: BarLocation; 26 | 27 | scrollKey?: any; 28 | 29 | passive?: boolean; 30 | 31 | onScroll?: (e: Event) => void; 32 | } 33 | 34 | export interface IComponentState { 35 | scrollWidth: number; 36 | scrollHeight: number; 37 | 38 | clientWidth: number; 39 | clientHeight: number; 40 | 41 | targetWidth?: number; 42 | targetHeight?: number; 43 | 44 | scrollLeft: number; 45 | scrollTop: number; 46 | 47 | hasScroll: boolean, 48 | 49 | dragPhase: string; 50 | mousePosition: number[]; 51 | scrollPosition: number[]; 52 | 53 | barLocation: BarLocation; 54 | } 55 | 56 | export class Stroller extends React.Component { 57 | 58 | state = { 59 | scrollWidth: 0, 60 | scrollHeight: 0, 61 | clientWidth: 0, 62 | clientHeight: 0, 63 | targetWidth: 0, 64 | targetHeight: 0, 65 | scrollLeft: 0, 66 | scrollTop: 0, 67 | dragPhase: 'idle', 68 | mousePosition: [0, 0], 69 | scrollPosition: [0, 0], 70 | barLocation: 'inside' as BarLocation, 71 | hasScroll: false, 72 | }; 73 | 74 | private dragMachine = DragMachine.create(); 75 | 76 | private topNode: HTMLElement | undefined = undefined; 77 | private scrollableParent: HTMLElement | undefined = undefined; 78 | private scrollContainer: HTMLElement | null = null; 79 | private barRef: HTMLElement | undefined = undefined; 80 | private dettachParentCallback: null | (() => void) = null; 81 | 82 | componentDidMount() { 83 | this.scrollableParent = findScrollableParent(this.scrollContainer || this.topNode!, this.props.axis); 84 | const scrollableParent: any = this.scrollableParent; 85 | 86 | const barLocation = this.props.overrideLocation || this.scrollableParent === document.body 87 | ? 'fixed' 88 | : ( 89 | (this.scrollContainer ? !this.topNode!.contains(this.scrollableParent) : true) 90 | ? 'inside' 91 | : 'outside' 92 | ); 93 | 94 | this.setState({ 95 | barLocation 96 | }); 97 | 98 | this.attach(barLocation === 'fixed' ? window : this.scrollableParent); 99 | 100 | this.onContainerScroll(); 101 | 102 | (this.dragMachine as any)._id = this; 103 | 104 | this.dragMachine 105 | .attrs({enabled: this.props.draggable}) 106 | .observe((dragPhase: string) => this.setState({dragPhase})) 107 | .connect((message: string, coords: number[]) => { 108 | if (message === 'down') { 109 | this.setState({ 110 | mousePosition: coords, 111 | scrollPosition: 112 | this.state.barLocation === 'fixed' 113 | ? [window.scrollX, window.scrollY] 114 | : [scrollableParent.scrollLeft, scrollableParent.scrollTop] 115 | }) 116 | } 117 | if (message === 'move') { 118 | const {axis = 'vertical', targetAxis: pTargetAxis} = this.props; 119 | const {mousePosition} = this.state; 120 | 121 | const targetAxis = pTargetAxis || axis; 122 | const axScroll = axisToProps[axis]; 123 | const axTarget = axisToProps[targetAxis]; 124 | 125 | const delta = [mousePosition[0] - coords[0], mousePosition[1] - coords[1]]; 126 | 127 | const st: any = this.state; 128 | 129 | const {space: axisSpace, scrollSpace: axisScrollSpace} = extractValues(st, axis); 130 | const {scrollSpace, targetSpace} = extractValues(st, targetAxis); 131 | 132 | const scrollFactor = 133 | axis === targetAxis 134 | ? scrollSpace / targetSpace 135 | : (axisScrollSpace - axisSpace) / targetSpace; 136 | 137 | const barPosition: any = scrollableParent.getBoundingClientRect(); 138 | if (this.state.barLocation === 'fixed') { 139 | const X = axis === 'vertical' ? st.scrollPosition[0] : st.scrollPosition[0] - delta[axTarget.coord] * scrollFactor; 140 | const Y = axis !== 'vertical' ? st.scrollPosition[1] : st.scrollPosition[1] - delta[axTarget.coord] * scrollFactor; 141 | window.scrollTo(X, Y); 142 | } else if (barPosition[axTarget.start] < coords[axTarget.coord] && barPosition[axTarget.end] > coords[axTarget.coord]) { 143 | scrollableParent[axScroll.scroll] = st.scrollPosition[axScroll.coord] - delta[axTarget.coord] * scrollFactor; 144 | } 145 | 146 | } 147 | }) 148 | .start('init') 149 | } 150 | 151 | componentWillUnmount() { 152 | this.dragMachine.destroy(); 153 | this.dettach(); 154 | } 155 | 156 | componentDidUpdate(prevProps: IStrollerProps) { 157 | this.dragMachine.attrs({enabled: this.props.draggable}); 158 | this.dragMachine.put('check'); 159 | if (this.props.scrollKey !== prevProps.scrollKey) { 160 | this.onContainerScroll(); 161 | } 162 | } 163 | 164 | private onContainerScroll = (e?: Event) => { 165 | const topNode = this.scrollableParent as any; 166 | 167 | const scrollLeft = topNode.scrollLeft; 168 | const scrollTop = topNode.scrollTop; 169 | 170 | const scrollWidth = topNode.scrollWidth; 171 | const scrollHeight = topNode.scrollHeight; 172 | 173 | const targetWidth = this.topNode?.clientWidth; 174 | const targetHeight = this.topNode?.clientHeight; 175 | 176 | const clientWidth = topNode.clientWidth; 177 | const clientHeight = topNode.clientHeight; 178 | 179 | 180 | const isFixed = this.state.barLocation === 'fixed'; 181 | 182 | const st: any = this.state; 183 | 184 | const {axis = 'vertical', onScroll } = this.props; 185 | 186 | const mainScroll = extractValues(st, axis); 187 | 188 | this.setState({ 189 | scrollWidth, 190 | scrollHeight, 191 | 192 | targetWidth, 193 | targetHeight, 194 | 195 | clientWidth: isFixed ? window.innerWidth : clientWidth, 196 | clientHeight: isFixed ? window.innerHeight : clientHeight, 197 | 198 | scrollLeft: isFixed ? window.scrollX : scrollLeft, 199 | scrollTop: isFixed ? window.scrollY : scrollTop, 200 | 201 | hasScroll: mainScroll.scrollSpace > mainScroll.space, 202 | }); 203 | 204 | if (onScroll && e) { 205 | onScroll(e); 206 | } 207 | }; 208 | 209 | private attach(parent: HTMLElement | Window) { 210 | this.dettach(); 211 | const {passive} = this.props; 212 | const options: any = passive && supportsPassiveEvents ? {passive: true} : undefined; 213 | parent.addEventListener('scroll', this.onContainerScroll, options); 214 | this.dettachParentCallback = () => { 215 | parent.removeEventListener('scroll', this.onContainerScroll, options); 216 | }; 217 | } 218 | 219 | private dettach() { 220 | if (this.dettachParentCallback) { 221 | this.dettachParentCallback(); 222 | this.dettachParentCallback = null; 223 | } 224 | } 225 | 226 | private setScrollContainer = (ref: HTMLElement | null) => this.scrollContainer = ref; 227 | 228 | private setTopNode = (topNode: HTMLElement) => this.topNode = topNode; 229 | 230 | private setBarRef = (barRef: HTMLElement) => { 231 | this.barRef = barRef; 232 | this.dragMachine 233 | .attrs({node: this.barRef}) 234 | .put('check'); 235 | }; 236 | 237 | private strollerProviderValue = { 238 | setScrollContainer: this.setScrollContainer 239 | }; 240 | 241 | render() { 242 | const { 243 | children, 244 | bar, 245 | inBetween, 246 | axis = 'vertical', 247 | targetAxis, 248 | oppositePosition = false, 249 | draggable = false, 250 | barSizeFunction, 251 | barClassName, 252 | SideBar, 253 | } = this.props; 254 | 255 | const {dragPhase} = this.state; 256 | const st: any = this.state; 257 | 258 | const ax = axisToProps[axis]; 259 | 260 | const scrollSpace: number = st[ax.scrollSpace]; 261 | 262 | const Bar = this.props.scrollBar || StollerBar; 263 | 264 | return ( 265 | 266 |
        267 | 268 | {children} 269 | 270 |
        271 | {inBetween} 272 |
        273 | {scrollSpace 274 | ? ( 275 | 295 | ) 296 | : null 297 | } 298 |
        299 |
        300 | ); 301 | } 302 | } --------------------------------------------------------------------------------