├── .github └── workflows │ ├── release.yml │ └── storybook.yml ├── .gitignore ├── .storybook ├── main.js └── preview.js ├── CHANGELOG.md ├── GUILD.md ├── LICENSE ├── README-CN.md ├── README.md ├── example ├── .npmignore ├── Body.stories.tsx ├── Demo.stories.tsx ├── DirectionX.stories.tsx ├── UseScrollWatch.stories.tsx ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── jest-puppeteer.config.js ├── jest.e2e.config.js ├── jest.unit.config.js ├── package.json ├── src ├── index.tsx ├── useScrollWatch.ts ├── useSmoothScroll.tsx └── utils.ts ├── stories ├── index.css ├── index.stories.tsx ├── useScrollWath.stories.tsx └── useSmoothScroll.stories.tsx ├── test ├── e2e │ └── useSmoothScroll.test.tsx └── specs │ └── utils.test.tsx ├── tsconfig.json └── yarn.lock /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - 'src/**' 8 | 9 | pull_request: 10 | branches: 11 | - master 12 | paths: 13 | - 'src/**' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Begin CI... 21 | uses: actions/checkout@v2 22 | 23 | - name: Use Node 12 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: 12.x 27 | 28 | - name: Use cached node_modules 29 | uses: actions/cache@v1 30 | with: 31 | path: node_modules 32 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | nodeModules- 35 | 36 | - name: Install dependencies 37 | run: yarn install --frozen-lockfile 38 | env: 39 | CI: true 40 | 41 | # - name: Lint 42 | # run: yarn lint 43 | # env: 44 | # CI: true 45 | 46 | - name: Test 47 | run: | 48 | yarn test --ci --coverage --maxWorkers=2 49 | yarn test:e2e --ci --coverage --maxWorkers=2 50 | env: 51 | CI: true 52 | 53 | - name: Release 54 | run: | 55 | yarn build 56 | npm run release 57 | npm run deploy-storybook -- --ci 58 | env: 59 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 60 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 61 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: Storybook 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - 'stories/**' 8 | - 'example/**' 9 | - README.md 10 | 11 | pull_request: 12 | branches: 13 | - master 14 | paths: 15 | - 'stories/**' 16 | - 'example/**' 17 | - README.md 18 | 19 | jobs: 20 | deploy: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Begin CI... 25 | uses: actions/checkout@v2 26 | 27 | - name: Use Node 12 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: 12.x 31 | 32 | - name: Use cached node_modules 33 | uses: actions/cache@v1 34 | with: 35 | path: node_modules 36 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 37 | restore-keys: | 38 | nodeModules- 39 | 40 | - name: Install dependencies 41 | run: yarn install --frozen-lockfile 42 | env: 43 | CI: true 44 | 45 | # - name: Lint 46 | # run: yarn lint 47 | # env: 48 | # CI: true 49 | 50 | # - name: Test 51 | # run: yarn test --ci --coverage --maxWorkers=2 52 | # env: 53 | # CI: true 54 | 55 | - name: Deploy 56 | run: | 57 | npm run deploy-storybook -- --ci 58 | env: 59 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 60 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | .history 7 | coverage -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const basePath = path.resolve(__dirname, '../', 'src'); 3 | 4 | module.exports = { 5 | stories: ['../stories/**/*.stories.@(jsx|tsx)'], 6 | addons: [ 7 | '@storybook/addon-actions', 8 | '@storybook/addon-links', 9 | '@storybook/addon-docs', 10 | ], 11 | webpackFinal: async config => { 12 | config.module.rules.push({ 13 | test: /\.(ts|tsx)$/, 14 | use: [ 15 | { 16 | loader: require.resolve('ts-loader'), 17 | options: { 18 | transpileOnly: true, 19 | }, 20 | }, 21 | { 22 | loader: require.resolve('react-docgen-typescript-loader'), 23 | }, 24 | ], 25 | }); 26 | config.resolve.alias = { 27 | 'react-smooth-scroll-hook': basePath, 28 | }; 29 | config.resolve.extensions.push('.ts', '.tsx'); 30 | 31 | return config; 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | options: { 3 | storySort: { order: ['Introduction', 'Main', 'More'] }, 4 | viewMode: 'docs', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.3.4](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.3.3...v1.3.4) (2020-10-27) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **useScrollWatch:** do not use ! optional chain ([b931a6d](https://github.com/ron0115/react-smooth-scroll-hook/commit/b931a6dbc7e376de5c78fc60bf2b13cd10f7f507)) 7 | 8 | ## [1.3.3](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.3.2...v1.3.3) (2020-08-29) 9 | 10 | 11 | ### Features 12 | 13 | * **API:** deprecated scrollToPage ([be27bde](https://github.com/ron0115/react-smooth-scroll-hook/commit/be27bdeea3af88e972ed29883680218d04ad9f31)) 14 | 15 | ## [1.3.2](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.3.1...v1.3.2) (2020-08-28) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * detect some event when dom change ([5dc4ef8](https://github.com/ron0115/react-smooth-scroll-hook/commit/5dc4ef83234550c22a005ebff1dc309b860697b3)) 21 | 22 | ## [1.3.1](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.3.0...v1.3.1) (2020-08-28) 23 | 24 | 25 | ### Features 26 | 27 | * useScrollWatch hook to detect scroll event ([7c0a42c](https://github.com/ron0115/react-smooth-scroll-hook/commit/7c0a42cf9ac47fb475eff980f2b6b9da24cb303f)) 28 | 29 | # [1.3.0](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.2.0...v1.3.0) (2020-08-27) 30 | 31 | 32 | ### Features 33 | 34 | * support documentParent mode ([0deb1bd](https://github.com/ron0115/react-smooth-scroll-hook/commit/0deb1bdaa5ee0824426cc0df320c276c79923b50)) 35 | 36 | # [1.2.0](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.1.0...v1.2.0) (2020-08-25) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * default to use native scrollTo ([de97546](https://github.com/ron0115/react-smooth-scroll-hook/commit/de9754651a2e33cb2d013df97a8350921748337d)) 42 | 43 | 44 | ### Features 45 | 46 | * support some state return from hook ([e884422](https://github.com/ron0115/react-smooth-scroll-hook/commit/e88442297d04d8f17d11547736b7863b9768afdc)) 47 | 48 | # [1.1.0](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.0.1...v1.1.0) (2020-08-25) 49 | 50 | 51 | ### Features 52 | 53 | * support some state return from hook ([e15e50d](https://github.com/ron0115/react-smooth-scroll-hook/commit/e15e50d536a283a55b19c579addf38590cf06be7)) 54 | 55 | ## [1.0.1](https://github.com/ron0115/react-smooth-scroll-hook/compare/v1.0.0...v1.0.1) (2020-08-22) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * default to use native scrollTo ([2b0b244](https://github.com/ron0115/react-smooth-scroll-hook/commit/2b0b244b6d3607907a0df6d42546a27a22c67544)) 61 | 62 | # 1.0.0 (2020-08-21) 63 | 64 | 65 | ### Features 66 | 67 | * initial first version ([e3f882b](https://github.com/ron0115/react-smooth-scroll-hook/commit/e3f882b8e9a1109743fac8e45b42bcc4b4244a13)) 68 | -------------------------------------------------------------------------------- /GUILD.md: -------------------------------------------------------------------------------- 1 | # TSDX React User Guide 2 | 3 | Congrats! You just saved yourself hours of work by bootstrapping this project with TSDX. Let’s get you oriented with what’s here and how to use it. 4 | 5 | > This TSDX setup is meant for developing React components (not apps!) that can be published to NPM. If you’re looking to build an app, you should use `create-react-app`, `razzle`, `nextjs`, `gatsby`, or `react-static`. 6 | 7 | > If you’re new to TypeScript and React, checkout [this handy cheatsheet](https://github.com/sw-yx/react-typescript-cheatsheet/) 8 | 9 | ## Commands 10 | 11 | TSDX scaffolds your new library inside `/src`, and also sets up a [Parcel-based](https://parceljs.org) playground for it inside `/example`. 12 | 13 | The recommended workflow is to run TSDX in one terminal: 14 | 15 | ``` 16 | npm start # or yarn start 17 | ``` 18 | 19 | This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`. 20 | 21 | Then run either example playground or storybook: 22 | 23 | ### Storybook 24 | 25 | Run inside another terminal: 26 | 27 | ``` 28 | yarn storybook 29 | ``` 30 | 31 | This loads the stories from `./stories`. 32 | 33 | > NOTE: Stories should reference the components as if using the library, similar to the example playground. This means importing from the root project directory. This has been aliased in the tsconfig and the storybook webpack config as a helper. 34 | 35 | ### Example 36 | 37 | Then run the example inside another: 38 | 39 | ``` 40 | cd example 41 | npm i # or yarn to install dependencies 42 | npm start # or yarn start 43 | ``` 44 | 45 | The default example imports and live reloads whatever is in `/dist`, so if you are seeing an out of date component, make sure TSDX is running in watch mode like we recommend above. **No symlinking required**, [we use Parcel's aliasing](https://github.com/palmerhq/tsdx/pull/88/files). 46 | 47 | To do a one-off build, use `npm run build` or `yarn build`. 48 | 49 | To run tests, use `npm test` or `yarn test`. 50 | 51 | ## Configuration 52 | 53 | Code quality is [set up for you](https://github.com/palmerhq/tsdx/pull/45/files) with `prettier`, `husky`, and `lint-staged`. Adjust the respective fields in `package.json` accordingly. 54 | 55 | ### Jest 56 | 57 | Jest tests are set up to run with `npm test` or `yarn test`. This runs the test watcher (Jest) in an interactive mode. By default, runs tests related to files changed since the last commit. 58 | 59 | #### Setup Files 60 | 61 | This is the folder structure we set up for you: 62 | 63 | ``` 64 | /example 65 | index.html 66 | index.tsx # test your component here in a demo app 67 | package.json 68 | tsconfig.json 69 | /src 70 | index.tsx # EDIT THIS 71 | /test 72 | blah.test.tsx # EDIT THIS 73 | .gitignore 74 | package.json 75 | README.md # EDIT THIS 76 | tsconfig.json 77 | ``` 78 | 79 | #### React Testing Library 80 | 81 | We do not set up `react-testing-library` for you yet, we welcome contributions and documentation on this. 82 | 83 | ### Rollup 84 | 85 | TSDX uses [Rollup v1.x](https://rollupjs.org) as a bundler and generates multiple rollup configs for various module formats and build settings. See [Optimizations](#optimizations) for details. 86 | 87 | ### TypeScript 88 | 89 | `tsconfig.json` is set up to interpret `dom` and `esnext` types, as well as `react` for `jsx`. Adjust according to your needs. 90 | 91 | ## Continuous Integration 92 | 93 | ### Travis 94 | 95 | _to be completed_ 96 | 97 | ### Circle 98 | 99 | _to be completed_ 100 | 101 | ## Optimizations 102 | 103 | Please see the main `tsdx` [optimizations docs](https://github.com/palmerhq/tsdx#optimizations). In particular, know that you can take advantage of development-only optimizations: 104 | 105 | ```js 106 | // ./types/index.d.ts 107 | declare var __DEV__: boolean; 108 | 109 | // inside your code... 110 | if (__DEV__) { 111 | console.log('foo'); 112 | } 113 | ``` 114 | 115 | You can also choose to install and use [invariant](https://github.com/palmerhq/tsdx#invariant) and [warning](https://github.com/palmerhq/tsdx#warning) functions. 116 | 117 | ## Module Formats 118 | 119 | CJS, ESModules, and UMD module formats are supported. 120 | 121 | The appropriate paths are configured in `package.json` and `dist/index.js` accordingly. Please report if any issues are found. 122 | 123 | ## Using the Playground 124 | 125 | ``` 126 | cd example 127 | npm i # or yarn to install dependencies 128 | npm start # or yarn start 129 | ``` 130 | 131 | The default example imports and live reloads whatever is in `/dist`, so if you are seeing an out of date component, make sure TSDX is running in watch mode like we recommend above. **No symlinking required**! 132 | 133 | ## Deploying the Playground 134 | 135 | The Playground is just a simple [Parcel](https://parceljs.org) app, you can deploy it anywhere you would normally deploy that. Here are some guidelines for **manually** deploying with the Netlify CLI (`npm i -g netlify-cli`): 136 | 137 | ```bash 138 | cd example # if not already in the example folder 139 | npm run build # builds to dist 140 | netlify deploy # deploy the dist folder 141 | ``` 142 | 143 | Alternatively, if you already have a git repo connected, you can set up continuous deployment with Netlify: 144 | 145 | ```bash 146 | netlify init 147 | # build command: yarn build && cd example && yarn && yarn build 148 | # directory to deploy: example/dist 149 | # pick yes for netlify.toml 150 | ``` 151 | 152 | ## Named Exports 153 | 154 | Per Palmer Group guidelines, [always use named exports.](https://github.com/palmerhq/typescript#exports) Code split inside your React app instead of your React library. 155 | 156 | ## Including Styles 157 | 158 | There are many ways to ship styles, including with CSS-in-JS. TSDX has no opinion on this, configure how you like. 159 | 160 | For vanilla CSS, you can include it at the root directory and add it to the `files` section in your `package.json`, so that it can be imported separately by your users and run through their bundler's loader. 161 | 162 | ## Publishing to NPM 163 | 164 | We recommend using https://github.com/sindresorhus/np. 165 | 166 | ## Usage with Lerna 167 | 168 | When creating a new package with TSDX within a project set up with Lerna, you might encounter a `Cannot resolve dependency` error when trying to run the `example` project. To fix that you will need to make changes to the `package.json` file _inside the `example` directory_. 169 | 170 | The problem is that due to the nature of how dependencies are installed in Lerna projects, the aliases in the example project's `package.json` might not point to the right place, as those dependencies might have been installed in the root of your Lerna project. 171 | 172 | Change the `alias` to point to where those packages are actually installed. This depends on the directory structure of your Lerna project, so the actual path might be different from the diff below. 173 | 174 | ```diff 175 | "alias": { 176 | - "react": "../node_modules/react", 177 | - "react-dom": "../node_modules/react-dom" 178 | + "react": "../../../node_modules/react", 179 | + "react-dom": "../../../node_modules/react-dom" 180 | }, 181 | ``` 182 | 183 | An alternative to fixing this problem would be to remove aliases altogether and define the dependencies referenced as aliases as dev dependencies instead. [However, that might cause other problems.](https://github.com/palmerhq/tsdx/issues/64) 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 liangzhirong 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. -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | # [react-smooth-scroll-hook](https://github.com/ron0115/react-smooth-scroll-hook) 2 | 3 | [![GitHub license](https://img.shields.io/github/license/ron0115/react-smooth-scroll-hook?style=flat)](https://github.com/ron0115/react-smooth-scroll-hook/blob/master/LICENSE) 4 | [![npm version](http://img.shields.io/npm/v/react-smooth-scroll-hook.svg?style=flat)](https://npmjs.org/package/react-smooth-scroll-hook) 5 | [![GitHub stars](https://img.shields.io/github/stars/ron0115/react-smooth-scroll-hook?style=flat)](https://github.com/ron0115/react-smooth-scroll-hook/stargazers) 6 | 7 | > Powered By GE-COMPONENTS From YY GFE TEAM 8 | 9 | 简体中文 | [Englist](./README.md) 10 | 11 | 提供 `useSmoothScroll` hook 完成在 react 项目中的平滑滚动, 同时, `useScrollWatch` 会返回一些滚动过程中的有用信息。 12 | 13 | 一个无痛的方式替换原生 `scrollTo` api. 14 | 15 | > **Storybook 文档 点击这里.** 16 | 17 | ## Feature 18 | 19 | - 🚀 不用担心兼容性, 使用`requsetAnimationFrame` api 实现平滑滚动. 20 | 21 | - 👉 提供 `direction` 选项 ,设置为`x` / `y`,同时支持水平/垂直滚动. 22 | 23 | - 💧 不依赖第三方工具,纯净且轻量. 24 | 25 | ## Installation 26 | 27 | ```sh 28 | npm install react-smooth-scroll-hook 29 | ``` 30 | 31 | ## useSmoothScroll 32 | 33 | ### 快速开始 34 | 35 | ```tsx 36 | import React, { useRef } from 'react'; 37 | import useSmoothScroll from 'react-smooth-scroll-hook'; 38 | export const Demo = () => { 39 | // A custom scroll container 40 | const ref = useRef(null); 41 | 42 | // Also support document.body / document.documentElement, and you don't need to set a ref passing to jsx 43 | const ref = useRef(document.body); 44 | 45 | const { scrollTo } = useSmoothScroll({ 46 | ref, 47 | speed: 100, 48 | direction: 'y', 49 | }); 50 | 51 | return ( 52 | <> 53 | 54 |
62 | {Array(100) 63 | .fill(null) 64 | .map((_item, i) => ( 65 |
66 | item-{i} 67 |
68 | ))} 69 |
70 | 71 | ); 72 | }; 73 | ``` 74 | 75 | ### Props 76 | 77 | - **ref:** `RefObject`, 滚动容器的 ref,通常设置为 `overflow: scroll`的容器, 如果是整个文档滚动,可以这样传入: `ref = useRef(document.documentElement)` 或者 `useRef(document.body)`. 78 | - **speed:** `requestAnimationFrame` 模式中,一帧的滚动距离, 默认值是 `100`。 79 | - **direction:** 滚动方向, `x` 横向 ,或者 `y` 纵向. 80 | - **threshold:** 判断滚动是否完成的临界距离, 默认为 `1`。 81 | 82 | #### Returns of Hook 83 | 84 | - **scrollTo** `(string|number) => void` 85 | 86 | - 传入 `number`的话: 代表滚动的距离(px), 例如 `scrollTo(400)`。 87 | - 传入 `string`的话: 代表滚动到的目标元素,此值透传到 `document.querySelector`, 例如. `scrollTo('#your-dom-id')` 88 | 89 | - **reachedTop** `boolean`: 是否到达 refContainer(滚动容器)的顶部。 90 | 91 | - **reachedBottom** `boolean`: 是否到达 refContainer(滚动容器)的底部。 92 | 93 | ### Demo 94 | 95 | - **CodeSandbox** 96 | - **Storybook** 97 | 98 | ## useScrollWatch 99 | 100 | 传入如下例子的`list`数组 , 同时提供滚动容器`ref` ,实时返回当前的滚动相关状态 `scrollTop`, `curIndex`, `curItem`等. 101 | 102 | ### Quick Start 103 | 104 | ```tsx 105 | import React, { useRef } from 'react'; 106 | import { useScrollWatch } from 'react-smooth-scroll-hook'; 107 | export const ScrollConatainerMode = () => { 108 | const ref = useRef(null); 109 | const { scrollTop, curIndex, curItem } = useScrollWatch({ 110 | ref, 111 | list: [ 112 | { 113 | href: '#item-0', 114 | }, 115 | { 116 | href: '#item-10', 117 | }, 118 | { 119 | href: '#item-20', 120 | }, 121 | ], 122 | }); 123 | return ( 124 | <> 125 |

Scroll Container Mode

126 |
127 |

128 | scrollTop: {scrollTop} 129 |

130 |

131 | curIndex: {curIndex} 132 |

133 |

134 | curHref: {curItem?.href} 135 |

136 |
137 |
145 | {Array(100) 146 | .fill(null) 147 | .map((_item, i) => ( 148 |
149 | item-{i} 150 |
151 | ))} 152 |
153 | 154 | ); 155 | }; 156 | ``` 157 | 158 | ### Props 159 | 160 | - **list** `Array({href, offset})`: `href` 代表元素的 selector, 透传到`querySelector`, 如 `#element-id` 161 | - **ref**: 见 `useSmoothScroll` 162 | 163 | ### Returns of Hook 164 | 165 | - **scrollTop** `number`: 当前滚动的 scrollTop. 166 | - **curIndex** `number`: 当前滚动到的`list`中的元素的`index`值 167 | - **curItem** `{href, offset}`: 当前滚动位置的`item` 168 | 169 | ### Demo 170 | 171 | - **CodeSandbox** 172 | - **Storybook** 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [react-smooth-scroll-hook](https://github.com/ron0115/react-smooth-scroll-hook) 2 | 3 | [![GitHub license](https://img.shields.io/github/license/ron0115/react-smooth-scroll-hook?style=flat)](https://github.com/ron0115/react-smooth-scroll-hook/blob/master/LICENSE) 4 | [![npm version](http://img.shields.io/npm/v/react-smooth-scroll-hook.svg?style=flat)](https://npmjs.org/package/react-smooth-scroll-hook) 5 | [![GitHub stars](https://img.shields.io/github/stars/ron0115/react-smooth-scroll-hook?style=flat)](https://github.com/ron0115/react-smooth-scroll-hook/stargazers) 6 | 7 | > Powered By GE-COMPONENTS From YY GFE TEAM 8 | 9 | English | [简体中文](./README-CN.md) 10 | 11 | It provided `useSmoothScroll` hook for finishing smooth scroll behaviour in react component, and `useScrollWatch` to return some information in scroll container. 12 | 13 | It 's a more convenient way to replace native `scrollTo` api. 14 | 15 | > **Storybook Docs are Here.** 16 | 17 | ## Feature 18 | 19 | - 🚀 You don't need to warn about compatibility, it use `requsetAnimationFrame` api to finish smooth scroll behaviour. 20 | 21 | - 👉 Provide `direction` option ,you can set `x` for horizontal, `y` for vertical. 22 | 23 | - 💧 No Third Party dependencies, light and pure. 24 | 25 | ## Installation 26 | 27 | ```sh 28 | npm install react-smooth-scroll-hook 29 | ``` 30 | 31 | ## useSmoothScroll 32 | 33 | ### Quick Start 34 | 35 | ```tsx 36 | import React, { useRef } from 'react'; 37 | import useSmoothScroll from 'react-smooth-scroll-hook'; 38 | export const Demo = () => { 39 | // A custom scroll container 40 | const ref = useRef(null); 41 | 42 | // Also support document.body / document.documentElement, and you don't need to set a ref passing to jsx 43 | const ref = useRef(document.body); 44 | 45 | const { scrollTo } = useSmoothScroll({ 46 | ref, 47 | speed: 100, 48 | direction: 'y', 49 | }); 50 | 51 | return ( 52 | <> 53 | 54 |
62 | {Array(100) 63 | .fill(null) 64 | .map((_item, i) => ( 65 |
66 | item-{i} 67 |
68 | ))} 69 |
70 | 71 | ); 72 | }; 73 | ``` 74 | 75 | ### Props 76 | 77 | - **ref:** `RefObject`, container which set as `overflow: scroll`, if scroll whole document, pass `ref = useRef(document.documentElement)` or `useRef(document.body)`. 78 | - **speed:** Distance in one frame to move in `requestAnimationFrame` mode, defaults to `100`, if not provide, speed depends on native API `scrollTo`. 79 | - **direction:** Scroll direction, `x` for horizontal or `y` for vertical. 80 | - **threshold:** an error range distance for status of scrolling finished, .defaults to `1`, unit of `px`. 81 | 82 | #### Returns of Hook 83 | 84 | - **scrollTo** `(string|number) => void` 85 | 86 | - Pass `number`: the distance to scroll, e.g. `scrollTo(400)` 87 | - Pass `string`: the element seletor you want to scrollTo, meanwhile passing to `document.querySelector`, e.g. `scrollTo('#your-dom-id')` 88 | 89 | - **reachedTop** `boolean`: Whether it has reached the top of refContainer 90 | 91 | - **reachedBottom** `boolean`: Whether it has reached the bottom of refContainer 92 | 93 | ### Demo 94 | 95 | - **CodeSandbox** 96 | - **Storybook** 97 | 98 | ## useScrollWatch 99 | 100 | Proviede a `list` of dom like below, and pass the parent container `ref` to hook, it return the scrollbar current state of `scrollTop`, `curIndex`, `curItem`. 101 | 102 | ### Quick Start 103 | 104 | ```tsx 105 | import React, { useRef } from 'react'; 106 | import { useScrollWatch } from 'react-smooth-scroll-hook'; 107 | export const ScrollConatainerMode = () => { 108 | const ref = useRef(null); 109 | const { scrollTop, curIndex, curItem } = useScrollWatch({ 110 | ref, 111 | list: [ 112 | { 113 | href: '#item-0', 114 | }, 115 | { 116 | href: '#item-10', 117 | }, 118 | { 119 | href: '#item-20', 120 | }, 121 | ], 122 | }); 123 | return ( 124 | <> 125 |

Scroll Container Mode

126 |
127 |

128 | scrollTop: {scrollTop} 129 |

130 |

131 | curIndex: {curIndex} 132 |

133 |

134 | curHref: {curItem?.href} 135 |

136 |
137 |
145 | {Array(100) 146 | .fill(null) 147 | .map((_item, i) => ( 148 |
149 | item-{i} 150 |
151 | ))} 152 |
153 | 154 | ); 155 | }; 156 | ``` 157 | 158 | ### Props 159 | 160 | - **list** `Array({href, offset})`: `href` is elemet selector string, which passing to `querySelector`, such as `#element-id` 161 | - **ref**: the same as ref of `useSmoothScroll` 162 | 163 | ### Returns of Hook 164 | 165 | - **scrollTop** `number`: current scrollTop of scroll container. 166 | - **curIndex** `number`: current Index of list 167 | - **curItem** `{href, offset}`: current Item of list 168 | 169 | ### Demo 170 | 171 | - **CodeSandbox** 172 | - **Storybook** 173 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/Body.stories.tsx: -------------------------------------------------------------------------------- 1 | import useSmoothScroll from 'react-smooth-scroll-hook'; 2 | import React, { useRef } from 'react'; 3 | 4 | export const Body = () => { 5 | const ref = useRef(document.documentElement); 6 | const { scrollTo } = useSmoothScroll({ 7 | ref, 8 | }); 9 | 10 | return ( 11 | <> 12 | 13 | 14 |
15 |
20 | {Array(100) 21 | .fill(null) 22 | .map((_item, i) => ( 23 |
24 | item-{i} 25 |
26 | ))} 27 |
28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /example/Demo.stories.tsx: -------------------------------------------------------------------------------- 1 | import useSmoothScroll from 'react-smooth-scroll-hook'; 2 | import React, { useRef } from 'react'; 3 | 4 | export const Demo = () => { 5 | const ref = useRef(null); 6 | const { scrollTo } = useSmoothScroll({ 7 | ref, 8 | }); 9 | 10 | return ( 11 | <> 12 | 15 | 16 |
17 |
26 | {Array(100) 27 | .fill(null) 28 | .map((_item, i) => ( 29 |
30 | y-item-{i} 31 |
32 | ))} 33 |
34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /example/DirectionX.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import useSmoothScroll from 'react-smooth-scroll-hook'; 3 | 4 | export const DirectionX = () => { 5 | const [speed, setSpeed] = useState(200); 6 | const ref = useRef(null); 7 | const { 8 | scrollTo, 9 | reachedTop, 10 | reachedBottom, 11 | containerSize, 12 | } = useSmoothScroll({ 13 | ref, 14 | direction: 'x', 15 | speed, 16 | }); 17 | const onChange = (evt: React.ChangeEvent) => { 18 | setSpeed(Number(evt.target.value)); 19 | }; 20 | return ( 21 | <> 22 |
23 |
29 |
35 | {Array(50) 36 | .fill(null) 37 | .map((_item, i) => ( 38 |
46 | x-item-{i} 47 |
48 | ))} 49 |
50 |
51 |
52 | speed:{speed} 53 |
54 | 62 |
63 |
64 | 65 | Pass string: 66 | 69 | 72 | 73 |
74 | 75 | Pass number: 76 | 77 | 78 | 79 |
80 | 81 | Scroll to Edge: 82 | 83 | 84 | 85 |
86 | 87 | Scroll to Page: 88 | 94 | 97 | 98 |
99 |
100 | reachedTop: {String(reachedTop)} 101 |
102 | reachedBottom: {String(reachedBottom)} 103 |
104 | 105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /example/UseScrollWatch.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useSmoothScroll, useScrollWatch } from 'react-smooth-scroll-hook'; 2 | import React, { useRef } from 'react'; 3 | 4 | export const ScrollConatainerMode = () => { 5 | const ref = useRef(null); 6 | const { scrollTop, curIndex, curItem } = useScrollWatch({ 7 | ref, 8 | list: [ 9 | { 10 | href: '#item-0', 11 | }, 12 | { 13 | href: '#item-10', 14 | }, 15 | { 16 | href: '#item-20', 17 | }, 18 | ], 19 | offset: -10, 20 | }); 21 | return ( 22 | <> 23 |

Scroll Container Mode

24 |
25 |

26 | scrollTop: {scrollTop} 27 |

28 |

29 | curIndex: {curIndex} 30 |

31 |

32 | curHref: {curItem?.href} 33 |

34 |
35 |
43 | {Array(100) 44 | .fill(null) 45 | .map((_item, i) => ( 46 |
47 | item-{i} 48 |
49 | ))} 50 |
51 | 52 | ); 53 | }; 54 | 55 | export const DirectionX = () => { 56 | const ref = useRef(null); 57 | const { scrollTop, curIndex, curItem } = useScrollWatch({ 58 | ref, 59 | direction: 'x', 60 | list: [ 61 | { 62 | href: '#x-item-0', 63 | }, 64 | { 65 | href: '#x-item-10', 66 | }, 67 | { 68 | href: '#x-item-20', 69 | }, 70 | ], 71 | offset: -10, 72 | }); 73 | return ( 74 | <> 75 |

Direction x Mode

76 |
77 |

78 | scrollTop: {scrollTop} 79 |

80 |

81 | curIndex: {curIndex} 82 |

83 |

84 | curHref: {curItem?.href} 85 |

86 |
87 |
95 |
100 | {Array(50) 101 | .fill(null) 102 | .map((_item, i) => ( 103 |
111 | x-item-{i} 112 |
113 | ))} 114 |
115 |
116 | 117 | ); 118 | }; 119 | 120 | export const WindowMode = () => { 121 | const ref = useRef(document.documentElement); 122 | const { scrollTop, curIndex, curItem } = useScrollWatch({ 123 | ref: ref, 124 | list: [ 125 | { 126 | href: '#p-item-0', 127 | }, 128 | { 129 | href: '#p-item-10', 130 | }, 131 | { 132 | href: '#p-item-20', 133 | }, 134 | ], 135 | // offset: -10, 136 | }); 137 | return ( 138 | <> 139 |

Window Parent Mode

140 |
147 | {Array(21) 148 | .fill(null) 149 | .map((_item, i) => ( 150 |
151 | p-item-{i} 152 |
153 | ))} 154 |
155 |
162 |

163 | scrollTop: {scrollTop} 164 |

165 |

166 | curIndex: {curIndex} 167 |

168 |

169 | curHref: {curItem?.href} 170 |

171 |
172 | 173 | ); 174 | }; 175 | 176 | export const UseScrollWatch = () => { 177 | return ( 178 | <> 179 | 180 | 181 | 182 | 183 | ); 184 | }; 185 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from "react-dom"; 2 | import * as React from "react"; 3 | import { Demo } from "./Demo.stories"; 4 | import { DirectionX } from "./DirectionX.stories"; 5 | // import { UseScrollWatch } from "./UseScrollWatch.stories"; 6 | import { Body } from "./Body.stories"; 7 | export default function App() { 8 | return ( 9 |
10 |

useSmoothScroll

11 |

Demo.stories.tsx

12 | 13 |

DirectionX.stories.tsx

14 | 15 |

Body.stories.tsx

16 | 17 |
18 | ); 19 | } 20 | 21 | ReactDOM.render(, document.getElementById("root")); 22 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "usesmoothscroll", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html --open", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react": "16.13.1", 12 | "react-app-polyfill": "^1.0.0", 13 | "react-dom": "16.13.1", 14 | "react-smooth-scroll-hook": "1.3.3" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^16.9.11", 18 | "@types/react-dom": "^16.8.4", 19 | "typescript": "^3.4.5", 20 | "@babel/core": "7.2.0", 21 | "parcel-bundler": "^1.6.1", 22 | "@types/node": "14.6.0" 23 | }, 24 | "keywords": [], 25 | "description": "" 26 | } -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "moduleResolution": "node", 7 | "noImplicitAny": false, 8 | "noUnusedLocals": false, 9 | "noUnusedParameters": false, 10 | "removeComments": true, 11 | "strictNullChecks": true, 12 | "preserveConstEnums": true, 13 | "sourceMap": true, 14 | "lib": ["es2015", "es2016", "dom"], 15 | "baseUrl": ".", 16 | "esModuleInterop": true, 17 | "allowSyntheticDefaultImports": true, 18 | "paths": { 19 | "react-smooth-scroll-hook": ["../src"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | command: 'npm run storybook', 4 | port: 6006, 5 | launchTimeout: 100000, 6 | debug: true, 7 | usedPortAction: 'ignore', 8 | }, 9 | launch: { 10 | // headless: false, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-puppeteer-preset', 3 | roots: ['/test/e2e/'], 4 | globals: { 5 | 'ts-jest': { 6 | // 关闭错误诊断 7 | diagnostics: false, 8 | }, 9 | }, 10 | collectCoverageFrom: ['/**/*.ts'], 11 | coverageDirectory: 'coverage', 12 | }; 13 | -------------------------------------------------------------------------------- /jest.unit.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'enzyme', 3 | setupFilesAfterEnv: ['./node_modules/jest-enzyme/lib/index.js'], 4 | roots: ['/test/specs/'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.3.4", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "homepage": "https://github.com/ron0115/react-smooth-scroll-hook#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/ron0115/react-smooth-scroll-hook.git" 14 | }, 15 | "description": "a series of react hooks to finish smooth scroll and wath scroll behavior", 16 | "keywords": [ 17 | "react", 18 | "hooks", 19 | "smooth-scroll", 20 | "scrollTo", 21 | "scroll", 22 | "util", 23 | "js", 24 | "javascript", 25 | "typescript" 26 | ], 27 | "engines": { 28 | "node": ">=10" 29 | }, 30 | "scripts": { 31 | "start": "tsdx watch", 32 | "build": "tsdx build", 33 | "test": "tsdx test --passWithNoTests --config jest.unit.config.js", 34 | "test:e2e": "tsdx test --passWithNoTests --config jest.e2e.config.js", 35 | "lint": "tsdx lint", 36 | "prepare": "tsdx build", 37 | "storybook": "start-storybook -p 6006 --docs", 38 | "build-storybook": "build-storybook --docs", 39 | "deploy-storybook": "storybook-to-ghpages", 40 | "release": "semantic-release" 41 | }, 42 | "peerDependencies": { 43 | "react": ">=16.8.0", 44 | "react-dom": ">=16.8.0" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "tsdx lint" 49 | } 50 | }, 51 | "prettier": { 52 | "printWidth": 80, 53 | "semi": true, 54 | "singleQuote": true, 55 | "trailingComma": "es5" 56 | }, 57 | "name": "react-smooth-scroll-hook", 58 | "author": "liangzhirong", 59 | "module": "dist/react-smooth-scroll-hook.esm.js", 60 | "devDependencies": { 61 | "@babel/core": "^7.11.4", 62 | "@semantic-release/changelog": "^5.0.1", 63 | "@semantic-release/commit-analyzer": "^8.0.1", 64 | "@semantic-release/git": "^9.0.0", 65 | "@semantic-release/npm": "^7.0.5", 66 | "@semantic-release/release-notes-generator": "^9.0.1", 67 | "@storybook/addon-actions": "^6.0.20", 68 | "@storybook/addon-docs": "^6.0.20", 69 | "@storybook/addon-links": "^6.0.20", 70 | "@storybook/addons": "^6.0.20", 71 | "@storybook/react": "^6.0.20", 72 | "@storybook/storybook-deployer": "^2.8.6", 73 | "@types/jest-environment-puppeteer": "^4.4.0", 74 | "@types/react": "^16.9.46", 75 | "@types/react-dom": "^16.9.8", 76 | "add": "^2.0.6", 77 | "babel-loader": "^8.1.0", 78 | "enzyme": "^3.11.0", 79 | "enzyme-adapter-react-16": "^1.15.5", 80 | "husky": "^4.2.5", 81 | "jest-enzyme": "^7.1.2", 82 | "jest-puppeteer-preset": "^4.4.0", 83 | "puppeteer": "^5.3.1", 84 | "react": "^16.13.1", 85 | "react-docgen-typescript-loader": "^3.7.2", 86 | "react-dom": "^16.13.1", 87 | "react-is": "^16.13.1", 88 | "semantic-release": "^17.1.1", 89 | "ts-jest": "^26.4.3", 90 | "ts-loader": "^8.0.2", 91 | "tsdx": "^0.13.2", 92 | "tslib": "^2.0.1", 93 | "typescript": "^4.0.2", 94 | "yarn": "^1.22.10" 95 | }, 96 | "publishConfig": { 97 | "access": "public" 98 | }, 99 | "release": { 100 | "plugins": [ 101 | [ 102 | "@semantic-release/commit-analyzer", 103 | { 104 | "releaseRules": [ 105 | { 106 | "type": "{feat,fix}", 107 | "release": "patch" 108 | } 109 | ] 110 | } 111 | ], 112 | "@semantic-release/release-notes-generator", 113 | "@semantic-release/changelog", 114 | "@semantic-release/npm", 115 | "@semantic-release/git", 116 | "@semantic-release/github" 117 | ], 118 | "branch": "master" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSmoothScroll } from './useSmoothScroll'; 2 | import { useScrollWatch } from './useScrollWatch'; 3 | export { useSmoothScroll, useScrollWatch }; 4 | export default useSmoothScroll; 5 | -------------------------------------------------------------------------------- /src/useScrollWatch.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { 3 | debounce, 4 | DirectionType, 5 | getAttrMap, 6 | isWindowScrollParent, 7 | } from './utils'; 8 | 9 | export type useScrollWathType = { 10 | /** the container dom RefObject which use `overflow:scroll`,if scroll whole document, pass `ref = useRef(document.documentElement)` or `useRef(document.body)`. */ 11 | ref: React.RefObject; 12 | list: { 13 | /** dom id of Element */ 14 | href: string; 15 | /** the scroll position judge preset of each Element */ 16 | offset?: number; 17 | }[]; 18 | /** global offset for every Element of list */ 19 | offset?: number; 20 | /** scroll axis, x for horizontal, y for vertical */ 21 | direction?: DirectionType; 22 | }; 23 | 24 | export const getCurIndex = (scrollTop: number, list: number[]) => { 25 | const length = list.length; 26 | if (!length) return -1; 27 | 28 | for (let i = 0; i < length; i++) { 29 | if (scrollTop < list[i]) { 30 | return i - 1; 31 | } 32 | } 33 | 34 | if (scrollTop >= list[length - 1]) { 35 | return list.length - 1; 36 | } 37 | 38 | return -1; 39 | }; 40 | 41 | export const useScrollWatch = (props: useScrollWathType) => { 42 | const { ref, list = [], offset, direction = 'y' } = props; 43 | 44 | const attrMap = getAttrMap(direction); 45 | 46 | const getScrollTop = () => { 47 | const elm = ref.current; 48 | if (!elm) return 0; 49 | return elm[attrMap.scrollLeftTop]; 50 | }; 51 | 52 | const [scrollTop, setScrollTop] = useState(getScrollTop() || 0); 53 | 54 | const getPosList = () => { 55 | let posList = list.map(item => { 56 | const parent = ref.current; 57 | const os = typeof item.offset === 'number' ? item.offset : offset || 0; 58 | const elm = document.querySelector(item.href); 59 | if (!elm) return Infinity; 60 | if (!parent) return Infinity; 61 | return isWindowScrollParent(parent) 62 | ? elm.getBoundingClientRect()[attrMap.leftTop] - 63 | parent.getBoundingClientRect()[attrMap.leftTop] + 64 | os 65 | : elm.getBoundingClientRect()[attrMap.leftTop] - 66 | parent.children[0].getBoundingClientRect()[attrMap.leftTop] + 67 | os; 68 | }); 69 | return posList; 70 | }; 71 | 72 | const refresh = debounce(() => { 73 | setScrollTop(getScrollTop()); 74 | setPosList(getPosList()); 75 | }, 100); 76 | 77 | const [posList, setPosList] = useState([]); 78 | 79 | useEffect(() => { 80 | refresh(); 81 | }, [ref, refresh]); 82 | 83 | const curIndex = getCurIndex(scrollTop, posList); 84 | 85 | useEffect(() => { 86 | if (!ref.current) return; 87 | const elm = isWindowScrollParent(ref.current) ? window : ref.current; 88 | const observer = new window.MutationObserver(refresh); 89 | observer.observe(ref.current, { 90 | childList: true, 91 | subtree: true, 92 | }); 93 | elm.addEventListener('scroll', refresh); 94 | return () => { 95 | observer.disconnect(); 96 | elm && elm.removeEventListener('scroll', refresh); 97 | }; 98 | }, [ref, refresh]); 99 | 100 | return { 101 | curIndex, 102 | scrollTop, 103 | curItem: list[curIndex] || {}, 104 | }; 105 | }; 106 | -------------------------------------------------------------------------------- /src/useSmoothScroll.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useEffect } from 'react'; 3 | import { 4 | debounce, 5 | getAttrMap, 6 | AttrMapType, 7 | Direction, 8 | DirectionType, 9 | isWindowScrollParent, 10 | } from './utils'; 11 | 12 | export type UseSmoothScrollType = { 13 | /** the container dom RefObject which use `overflow:scroll`, if scroll whole document, pass `ref = useRef(document.documentElement)` or `useRef(document.body)`. */ 14 | ref: React.RefObject; 15 | /** distance per frame, reflects to speed while scrolling */ 16 | speed?: number; 17 | /** scroll direction, you can use 'x` for vertical, 'y` for horizontal */ 18 | direction?: DirectionType; 19 | /** allowable distance beteween nowable state the judgement edge */ 20 | threshold?: number; 21 | }; 22 | 23 | // get the relative distance from destination 24 | export const getRelativeDistance = ( 25 | target: number | string | undefined, 26 | parent: HTMLElement, 27 | attrMap: AttrMapType 28 | ) => { 29 | if (typeof target === 'number') return target; 30 | if (typeof target === 'string') { 31 | const elm = document.querySelector(target); 32 | if (!elm) { 33 | console.warn('Please pass correct selector string for scrollTo()!'); 34 | return 0; 35 | } 36 | let dis = 0; 37 | 38 | // if parent is document.documentElement or document.body 39 | if (isWindowScrollParent(parent)) { 40 | dis = elm.getBoundingClientRect()[attrMap.leftTop]; 41 | } else { 42 | dis = 43 | elm.getBoundingClientRect()[attrMap.leftTop] - 44 | parent.getBoundingClientRect()[attrMap.leftTop]; 45 | } 46 | 47 | return dis; 48 | } 49 | return 0; 50 | }; 51 | 52 | export const useSmoothScroll = ({ 53 | ref, 54 | speed = 100, 55 | direction = Direction.Y, 56 | threshold = 1, 57 | }: UseSmoothScrollType) => { 58 | const attrMap = getAttrMap(direction); 59 | 60 | const [reachedTop, setReachedTop] = useState(true); 61 | const [reachedBottom, setReachedBottom] = useState(true); 62 | const [size, setSize] = useState(0); 63 | 64 | const isTopEdge = () => { 65 | const elm = ref.current; 66 | if (!elm) return false; 67 | return elm[attrMap.scrollLeftTop] === 0; 68 | }; 69 | 70 | const isBottomEdge = () => { 71 | const elm = ref.current; 72 | if (!elm) return false; 73 | return ( 74 | Math.abs( 75 | elm[attrMap.scrollLeftTop] + 76 | elm[attrMap.clientWidthHeight] - 77 | elm[attrMap.scrollWidthHeight] 78 | ) < threshold 79 | ); 80 | }; 81 | 82 | const refreshSize = debounce(() => { 83 | if (ref.current) { 84 | const size = ref.current[attrMap.clientWidthHeight]; 85 | setSize(size); 86 | } 87 | }); 88 | 89 | const refreshState = debounce((_evt: Event) => { 90 | isTopEdge() ? setReachedTop(true) : setReachedTop(false); 91 | isBottomEdge() ? setReachedBottom(true) : setReachedBottom(false); 92 | }); 93 | 94 | const scrollTo = (target?: number | string, offset?: number) => { 95 | if (!ref || !ref.current) { 96 | console.warn( 97 | 'Please pass `ref` property for your scroll container! \n Get more info at https://github.com/ron0115/react-smooth-scroll-hook' 98 | ); 99 | return; 100 | } 101 | const elm = ref.current; 102 | if (!elm) return; 103 | if (!target && typeof target !== 'number') { 104 | console.warn( 105 | 'Please pass a valid property for `scrollTo()`! \n Get more info at https://github.com/ron0115/react-smooth-scroll-hook' 106 | ); 107 | } 108 | 109 | const initScrollLeftTop = elm[attrMap.scrollLeftTop]; 110 | 111 | let distance = getRelativeDistance(target, elm, attrMap); 112 | 113 | // set a offset 114 | if (typeof offset === 'number') { 115 | distance += offset; 116 | } 117 | 118 | let _speed = speed; 119 | const cb = () => { 120 | refreshState(); 121 | 122 | if (distance === 0) return; 123 | 124 | if ((isBottomEdge() && distance > 9) || (distance < 0 && isTopEdge())) 125 | return; 126 | 127 | const gone = () => 128 | Math.abs(elm[attrMap.scrollLeftTop] - initScrollLeftTop); 129 | 130 | if (Math.abs(distance) - gone() < _speed) { 131 | _speed = Math.abs(distance) - gone(); 132 | } 133 | 134 | // distance to run every frame,always 1/60s 135 | elm[attrMap.scrollLeftTop] += _speed * (distance > 0 ? 1 : -1); 136 | 137 | // reach destination, threshold defaults to 1 138 | if (Math.abs(gone() - Math.abs(distance)) < threshold) { 139 | return; 140 | } 141 | 142 | requestAnimationFrame(cb); 143 | }; 144 | requestAnimationFrame(cb); 145 | }; 146 | 147 | // detect dom changes 148 | useEffect(() => { 149 | if (!ref.current) return; 150 | 151 | refreshState(); 152 | refreshSize(); 153 | const observer = new MutationObserver((mutationsList, _observer) => { 154 | // Use traditional 'for loops' for IE 11 155 | for (const mutation of mutationsList) { 156 | if ( 157 | mutation.type === 'attributes' && 158 | mutation.target instanceof Element 159 | ) { 160 | refreshSize(); 161 | } 162 | } 163 | }); 164 | observer.observe(ref.current, { 165 | attributes: true, 166 | }); 167 | window.addEventListener('resize', refreshSize); 168 | return () => { 169 | observer.disconnect(); 170 | window.removeEventListener('resize', refreshSize); 171 | }; 172 | }, [ref, refreshState, refreshSize]); 173 | 174 | // detect scrollbar changes 175 | useEffect(() => { 176 | if (!ref.current) return; 177 | const elm = ref.current; 178 | const observer = new MutationObserver((mutationsList, _observer) => { 179 | // Use traditional 'for loops' for IE 11 180 | for (const mutation of mutationsList) { 181 | if ( 182 | mutation.type === 'childList' && 183 | mutation.target instanceof Element 184 | ) { 185 | refreshState(); 186 | } 187 | } 188 | }); 189 | observer.observe(elm, { 190 | childList: true, 191 | subtree: true, 192 | }); 193 | elm.addEventListener('scroll', refreshState); 194 | return () => { 195 | observer.disconnect(); 196 | elm && elm.removeEventListener('scroll', refreshState); 197 | }; 198 | }, [ref, refreshState]); 199 | 200 | return { 201 | reachedTop, 202 | reachedBottom, 203 | containerSize: size, 204 | scrollTo, 205 | /** @deprecated replace with scrollTo(n * containerSize) */ 206 | scrollToPage: (page: number) => { 207 | scrollTo(page * size); 208 | }, 209 | /** @deprecated */ 210 | refreshState, 211 | /** @deprecated */ 212 | refreshSize, 213 | }; 214 | }; 215 | export default useSmoothScroll; 216 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export enum Direction { 2 | X = 'x', 3 | Y = 'y', 4 | } 5 | export type DirectionType = Direction | 'x' | 'y'; 6 | export type AttrMapType = { 7 | scrollLeftTop: 'scrollLeft' | 'scrollTop'; 8 | scrollWidthHeight: 'scrollWidth' | 'scrollHeight'; 9 | clientWidthHeight: 'clientWidth' | 'clientHeight'; 10 | offsetLeftTop: 'offsetLeft' | 'offsetTop'; 11 | offsetWidthHeight: 'offsetWidth' | 'offsetHeight'; 12 | leftTop: 'top' | 'left'; 13 | }; 14 | export const getAttrMap = (direction: DirectionType) => { 15 | return { 16 | leftTop: Direction.X === direction ? 'left' : 'top', 17 | offsetLeftTop: Direction.X === direction ? 'offsetLeft' : 'offsetTop', 18 | offsetWidthHeight: 19 | Direction.X === direction ? 'offsetWidth' : 'offsetHeight', 20 | scrollLeftTop: Direction.X === direction ? 'scrollLeft' : 'scrollTop', 21 | scrollWidthHeight: 22 | Direction.X === direction ? 'scrollWidth' : 'scrollHeight', 23 | clientWidthHeight: 24 | Direction.X === direction ? 'clientWidth' : 'clientHeight', 25 | } as AttrMapType; 26 | }; 27 | 28 | export function debounce(cb: Function, delay = 100) { 29 | let timer: NodeJS.Timeout; 30 | return function(...args: any) { 31 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 32 | // @ts-ignore 33 | // eslint-disable-next-line @typescript-eslint/no-this-alias 34 | const _this = this; 35 | if (timer) clearTimeout(timer); 36 | timer = setTimeout(() => { 37 | cb.apply(_this, args); 38 | }, delay); 39 | }; 40 | } 41 | 42 | // judge body or documentElement 43 | export const isWindowScrollParent = (elm: HTMLElement) => { 44 | return !elm.parentElement || !elm.parentElement.parentElement; 45 | }; 46 | -------------------------------------------------------------------------------- /stories/index.css: -------------------------------------------------------------------------------- 1 | button { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Description } from '@storybook/addon-docs/dist/blocks'; 3 | import './index.css'; 4 | 5 | export default { 6 | title: 'Introduction', 7 | }; 8 | export const Docs = () => ( 9 | <> 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /stories/useScrollWath.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useScrollWatch } from '../src/index'; 3 | import './index.css'; 4 | 5 | export default { 6 | title: 'More/useScrollWatch', 7 | component: useScrollWatch, 8 | }; 9 | 10 | import { 11 | ScrollConatainerMode, 12 | WindowMode, 13 | DirectionX, 14 | } from '../example/UseScrollWatch.stories'; 15 | export const Docs = () => { 16 | return <>; 17 | }; 18 | 19 | export { WindowMode, ScrollConatainerMode, DirectionX }; 20 | 21 | // @ts-ignore 22 | WindowMode.storyName = 'Window Parent Mode'; 23 | // @ts-ignore 24 | ScrollConatainerMode.storyName = 'ScrollConatainer Mode'; 25 | // @ts-ignore 26 | DirectionX.storyName = 'Direction X'; 27 | -------------------------------------------------------------------------------- /stories/useSmoothScroll.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSmoothScroll from '../src/index'; 3 | 4 | import './index.css'; 5 | 6 | export default { 7 | title: 'Main/useSmoothScroll', 8 | component: useSmoothScroll, 9 | }; 10 | import { Demo } from '../example/Demo.stories'; 11 | import { DirectionX } from '../example/DirectionX.stories'; 12 | import { Body } from '../example/Body.stories'; 13 | 14 | export const Docs = () => <>; 15 | export { Demo, DirectionX, Body }; 16 | 17 | // @ts-ignore 18 | Demo.storyName = 'Basic Usage'; 19 | // @ts-ignore 20 | DirectionX.storyName = 'More Use'; 21 | // @ts-ignore 22 | Body.storyName = 'Body Parent'; 23 | -------------------------------------------------------------------------------- /test/e2e/useSmoothScroll.test.tsx: -------------------------------------------------------------------------------- 1 | // import fs from 'fs' 2 | import expectP from 'expect-puppeteer'; 3 | import { ElementHandle } from 'puppeteer'; 4 | const getItem = async (id: string) => { 5 | const res = await page.$(`#${id}`); 6 | return res; 7 | }; 8 | // isIntersectingViewport: allow to judge by threshold 9 | // https://github.com/puppeteer/puppeteer/issues/1665#issuecomment-429356629 10 | async function isIntersectingViewport( 11 | elm: ElementHandle, 12 | options?: { 13 | threshold?: number; 14 | } 15 | ): Promise { 16 | return await elm.evaluate<(element: Element, options) => Promise>( 17 | async (element, options) => { 18 | const { threshold = 0 } = options || {}; 19 | const visibleRatio = await new Promise(resolve => { 20 | const observer = new IntersectionObserver(entries => { 21 | resolve(entries[0].intersectionRatio); 22 | observer.disconnect(); 23 | }); 24 | observer.observe(element); 25 | }); 26 | return Number(visibleRatio) + threshold > 0; 27 | }, 28 | options 29 | ); 30 | } 31 | 32 | // const url = 'https://ron0115.github.io/react-smooth-scroll-hook'; 33 | const url = 'http://localhost:6006'; 34 | 35 | describe('useSmoothScroll', () => { 36 | // jest.setTimeout(30000); 37 | beforeEach(async () => { 38 | // const headlessUserAgent = await page.evaluate(() => navigator.userAgent); 39 | // const chromeUserAgent = headlessUserAgent.replace( 40 | // 'HeadlessChrome', 41 | // 'Chrome' 42 | // ); 43 | // await page.setUserAgent(chromeUserAgent); 44 | await page.setExtraHTTPHeaders({ 45 | 'accept-language': 'en-US,en;q=0.8', 46 | }); 47 | await page.setViewport({ 48 | width: 800, 49 | height: 4000, 50 | }); 51 | // hack:must load storybook's iframe directly 52 | await page.goto( 53 | `${url}/iframe.html?id=main-usesmoothscroll--docs&viewMode=docs#stories` 54 | ); 55 | await page.waitForSelector('#root'); 56 | // test code below 57 | // await page.waitFor(3000); 58 | // const test = await page.content(); 59 | // fs.writeFileSync('writeMe.html', test); 60 | }); 61 | 62 | test('showld scrollTo 400 distance', async () => { 63 | const demoWrapElm = await page.$(`#demo-stories`); 64 | await expectP(page).toClick('button', { 65 | text: `scrollTo(400)`, 66 | }); 67 | await page.waitFor(1000); 68 | expect(await page.evaluate(element => element.scrollTop, demoWrapElm)).toBe( 69 | 400 70 | ); 71 | }); 72 | 73 | test('should scrollTo target node y-item-20', async () => { 74 | expect(await (await getItem('x-item-20')).isIntersectingViewport()).toBe( 75 | false 76 | ); 77 | 78 | await expectP(page).toClick('button', { 79 | text: `scrollTo('#y-item-20')`, 80 | }); 81 | 82 | await page.waitFor(1000); 83 | 84 | expect(await (await getItem('y-item-20')).isIntersectingViewport()).toBe( 85 | true 86 | ); 87 | expect(await (await getItem('y-item-19')).isIntersectingViewport()).toBe( 88 | false 89 | ); 90 | }); 91 | const DirectionXCommon = async (speed = 200) => { 92 | test('scrollTo Bottom', async () => { 93 | await expectP(page).toClick('button', { 94 | text: `scrollTo Bottom`, 95 | }); 96 | await expectP(page).toMatch('reachedTop: false'); 97 | await expectP(page).toMatch('reachedBottom: true'); 98 | 99 | await expect( 100 | await (await getItem('x-item-49')).isIntersectingViewport() 101 | ).toBe(true); 102 | }); 103 | test('scrollTo Top', async () => { 104 | await expectP(page).toClick('button', { 105 | text: `scrollTo Top`, 106 | }); 107 | await expectP(page).toMatch('reachedTop: true'); 108 | await expectP(page).toMatch('reachedBottom: false'); 109 | 110 | await expect( 111 | await (await getItem('x-item-0')).isIntersectingViewport() 112 | ).toBe(true); 113 | }); 114 | test('scrollTo #x-item-20', async () => { 115 | await expectP(page).toFill('input[name="speed"]', String(speed)); 116 | await expect( 117 | await (await getItem('x-item-20')).isIntersectingViewport() 118 | ).toBe(false); 119 | 120 | await expectP(page).toClick('button', { 121 | text: `scrollTo('#x-item-20')`, 122 | }); 123 | 124 | await page.waitFor(2000); 125 | 126 | await expect( 127 | await (await getItem('x-item-20')).isIntersectingViewport() 128 | ).toBe(true); 129 | 130 | await expect( 131 | await isIntersectingViewport(await getItem('x-item-19'), { 132 | threshold: -0.01, 133 | }) 134 | ).toBe(false); 135 | }); 136 | }; 137 | DirectionXCommon(); 138 | DirectionXCommon(400); 139 | }); 140 | -------------------------------------------------------------------------------- /test/specs/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import { getAttrMap } from '../../src/utils'; 2 | import { getCurIndex } from '../../src/useScrollWatch'; 3 | describe('test src/utils', () => { 4 | describe('getAttrMap', () => { 5 | it('should return exact map when pass x', () => { 6 | expect(getAttrMap('x')).toMatchObject({ 7 | scrollLeftTop: 'scrollLeft', 8 | scrollWidthHeight: 'scrollWidth', 9 | clientWidthHeight: 'clientWidth', 10 | offsetLeftTop: 'offsetLeft', 11 | offsetWidthHeight: 'offsetWidth', 12 | leftTop: 'left', 13 | }); 14 | }); 15 | it('should return exact map when pass y', () => { 16 | expect(getAttrMap('y')).toMatchObject({ 17 | scrollLeftTop: 'scrollTop', 18 | scrollWidthHeight: 'scrollHeight', 19 | clientWidthHeight: 'clientHeight', 20 | offsetLeftTop: 'offsetTop', 21 | offsetWidthHeight: 'offsetHeight', 22 | leftTop: 'top', 23 | }); 24 | }); 25 | }); 26 | 27 | describe('getCurIndex', () => { 28 | expect(getCurIndex(0, [])).toBe(-1); 29 | expect(getCurIndex(100, [101, 200])).toBe(-1); 30 | expect(getCurIndex(100, [100, 200])).toBe(0); 31 | expect(getCurIndex(102, [101, 200])).toBe(0); 32 | expect(getCurIndex(199, [101, 200])).toBe(0); 33 | expect(getCurIndex(200, [101, 200])).toBe(1); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | // https://github.com/puppeteer/puppeteer/issues/1665#issuecomment-429356629 6 | "target": "es2018", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "rootDir": "./src", 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "moduleResolution": "node", 18 | "baseUrl": "./", 19 | "paths": { 20 | "@": ["./"], 21 | "*": ["src/*", "node_modules/*"] 22 | }, 23 | "jsx": "react", 24 | "esModuleInterop": true 25 | } 26 | } 27 | --------------------------------------------------------------------------------