├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── e2e │ └── spec.cy.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ └── e2e.ts └── tsconfig.json ├── docs └── demo.gif ├── example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── random-color-generator.ts │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── renovate.json ├── src └── index.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 2 * * 2' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages (go, javascript, csharp, python, cpp, java) 34 | with: 35 | languages: javascript 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | pull_request: 5 | branches: [ master ] 6 | # Allows to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 16 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | 25 | strategy: 26 | matrix: 27 | node-version: [18.x, 20.x] 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | - run: env | sort 36 | 37 | - name: Install React 38 | run: npm install react react-dom prop-types 39 | - run: npm install 40 | - name: Install example 41 | run: cd ./example && npm install && cd .. 42 | 43 | - run: npm run build 44 | - name: Run Cypress tests 45 | run: | 46 | npm run build & 47 | cd ./example && npm run start:silent & 48 | npm run cypress:run -- --record --key ${{ secrets.CYPRESS_KEY }} 49 | 50 | buildDemo: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v3 54 | - name: Install React 55 | run: npm install react react-dom prop-types 56 | - run: npm install 57 | - run: npm run build 58 | - name: Install example 59 | run: cd ./example && npm install && cd .. 60 | 61 | # By default, react builds the index.html links with absolute paths. However, the github pages deployment is under a folder name (usually the repo name). Therefore, we must make sure that the index.html links are relative 62 | - name: Set public url 63 | run: cd ./example && echo "PUBLIC_URL=." >> $GITHUB_ENV 64 | 65 | - name: Build demo 66 | run: cd ./example && npm run build && cd .. 67 | - name: Publish demo artifact 68 | uses: actions/upload-pages-artifact@v3 69 | with: 70 | path: ./example/build 71 | 72 | deployGithubPages: 73 | needs: buildDemo 74 | if: github.ref == 'refs/heads/master' 75 | 76 | environment: 77 | name: github-pages 78 | url: ${{ steps.deployment.outputs.page_url }} 79 | 80 | runs-on: ubuntu-latest 81 | steps: 82 | - name: Deploy to GitHub Pages 83 | id: deployment 84 | uses: actions/deploy-pages@v4 85 | 86 | 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | .idea 25 | 26 | cypress/videos 27 | cypress/screenshots 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | 4 | ## [2.0.2](https://github.com/dizco/react-scrollable-feed/compare/v2.0.1...v2.0.2) (2024-05-11) 5 | * Add debug flag 6 | 7 | 8 | ## [2.0.1](https://github.com/dizco/react-scrollable-feed/compare/v2.0.0...v2.0.1) (2023-12-31) 9 | * Remove src and other directories from the published package 10 | 11 | 12 | ## [2.0.0](https://github.com/dizco/react-scrollable-feed/compare/v1.3.2...v2.0.0) (2023-12-31) 13 | * Fix issue with children props typing ([#87](https://github.com/dizco/react-scrollable-feed/issues/87)) 14 | * Updated dependencies 15 | * Build with Node 18 and Node 20 16 | * Rebuild package bundling with [developit/microbundle ](https://github.com/developit/microbundle) instead of Rollup 17 | * Updated license to BSD 3-clause 18 | 19 | 20 | ## [1.3.2](https://github.com/dizco/react-scrollable-feed/compare/v1.3.1...v1.3.2) (2023-01-14) 21 | * Updated dependencies 22 | 23 | 24 | ## [1.3.1](https://github.com/dizco/react-scrollable-feed/compare/v1.3.0...v1.3.1) (2021-05-16) 25 | * Updated dependencies 26 | 27 | 28 | ## [1.3.0](https://github.com/dizco/react-scrollable-feed/compare/v1.2.0...v1.3.0) (2021-02-11) 29 | * Added `scrollToBottom` public method 30 | * Fix issue with scrolling when wrapper has fixed height ([#34](https://github.com/dizco/react-scrollable-feed/issues/34)) 31 | * Updated dependencies 32 | 33 | 34 | ## [1.2.0](https://github.com/dizco/react-scrollable-feed/compare/v1.1.2...v1.2.0) (2020-09-30) 35 | * Added `onScroll` prop 36 | * Updated dependencies 37 | 38 | 39 | ## [1.1.2](https://github.com/dizco/react-scrollable-feed/compare/v1.1.1...v1.1.2) (2020-01-01) 40 | * Updated dependencies 41 | 42 | 43 | ## [1.1.1](https://github.com/dizco/react-scrollable-feed/compare/v1.1.0...v1.1.1) (2019-12-03) 44 | * Fix issue with scrolling on Edge, Firefox ([#20](https://github.com/dizco/react-scrollable-feed/issues/20)) 45 | 46 | 47 | ## [1.1.0](https://github.com/dizco/react-scrollable-feed/compare/v1.0.4...v1.1.0) (2019-11-10) 48 | * Added `className` prop 49 | * Updated dependencies 50 | 51 | 52 | ## [1.0.4](https://github.com/dizco/react-scrollable-feed/compare/v1.0.3...v1.0.4) (2019-07-09) 53 | * Updated dependencies 54 | 55 | 56 | ## [1.0.3](https://github.com/dizco/react-scrollable-feed/compare/v1.0.2...v1.0.3) (2019-01-30) 57 | * Fixed issue with the automatic bottom detection ([#7](https://github.com/dizco/react-scrollable-feed/issues/7)) 58 | 59 | 60 | ## [1.0.2](https://github.com/dizco/react-scrollable-feed/compare/v1.0.1...v1.0.2) (2018-11-25) 61 | * Updated dependencies 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | 3 | Local development is broken into two parts (ideally using two tabs). 4 | 5 | First, run microbundle to watch your `src/` module and automatically recompile it into `dist/` whenever you make changes. 6 | 7 | ```bash 8 | npm run dev # runs with watch flag 9 | ``` 10 | 11 | The second part will be running the `example/` create-react-app that's linked to the local version of your module. 12 | 13 | ```bash 14 | # (in another tab) 15 | cd example 16 | npm start # runs create-react-app dev server 17 | ``` 18 | 19 | Now, anytime you make a change to your library in `src/` or to the example app's `example/src`, `create-react-app` will live-reload your local dev server so you can iterate on your component in real-time. 20 | 21 | ![](https://media.giphy.com/media/12NUbkX6p4xOO4/giphy.gif) 22 | 23 | 24 | #### Publishing to npm 25 | 26 | ```bash 27 | npm publish 28 | ``` 29 | 30 | This builds `cjs`, `es` and 'modern' versions of your module to `dist/` and then publishes your module to `npm`. 31 | 32 | Make sure that any npm modules you want as peer dependencies are properly marked as `peerDependencies` in `package.json`. The microbundle config will automatically recognize them as peers and not try to bundle them in your module. 33 | 34 | 35 | #### Deploying to Github Pages 36 | 37 | ```bash 38 | npm run deploy 39 | ``` 40 | 41 | This creates a production build of the example `create-react-app` that showcases your library and then runs `gh-pages` to deploy the resulting bundle. 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Gabriel Bourgault 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

react-scrollable-feed

2 |

Smart scrolling for chat UIs and feeds

3 |

4 | 5 | Build Status 6 | 7 | 8 | Cypress Dashboard 9 | 10 | 11 | NPM latest version 12 | 13 | 14 | PRs Welcome 15 | 16 |

17 | 18 | UX-wise, asking a user to scroll down manually a chat box when new messages arrive is quite painful. **react-scrollable-feed** aims to alleviate the burden of managing scrolling concerns from React developers. The same concept applies to any other kind of feed where new content arrives dynamically. 19 | 20 | ## Demo 21 | 22 | View a live demo [here](https://dizco.github.io/react-scrollable-feed/). 23 | 24 | ![Live demo gif](docs/demo.gif) 25 | 26 | ## Install 27 | 28 | ```bash 29 | npm install --save react-scrollable-feed 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```tsx 35 | import * as React from 'react' 36 | 37 | import ScrollableFeed from 'react-scrollable-feed' 38 | 39 | class App extends React.Component { 40 | render() { 41 | const items = ['Item 1', 'Item 2']; 42 | 43 | return ( 44 | 45 | {items.map((item, i) =>
{item}
)} 46 |
47 | ); 48 | } 49 | } 50 | ``` 51 | 52 | ## Options 53 | 54 | ### forceScroll 55 | 56 | - Type: `boolean` 57 | - Default: `false` 58 | 59 | If set to true, will scroll to the bottom after each update on the component. By default, if the scrollable section is not at the bottom _before_ the update occurs, it will leave the scroll at the same position. 60 | 61 | ### animateScroll 62 | 63 | - Type: `(element: HTMLElement, offset: number) => void` 64 | - Default: 65 | ```ts 66 | if (element.scrollBy) { 67 | element.scrollBy({ top: offset }); 68 | } 69 | else { 70 | element.scrollTop = offset; 71 | } 72 | ``` 73 | 74 | Allows to override the scroll animation by any implementation. 75 | 76 | ### onScrollComplete 77 | 78 | - Type: `() => void` 79 | - Default: `() => {}` 80 | 81 | Is called after the scroll animation has been executed. 82 | 83 | ### changeDetectionFilter 84 | 85 | - Type: `(previousProps: ScrollableFeedComponentProps, newProps: ScrollableFeedComponentProps) => boolean` 86 | - Default: `() => true` 87 | 88 | Allows to customize _when_ the scroll should occur. This will be called everytime a `componentDidUpdate` happens, which means everytime one of the props changes. You will receive as parameters the previous and the new props. 89 | 90 | Note: `ScrollableFeedComponentProps` is defined as `React.PropsWithChildren` 91 | 92 | If you want to compare the last children from both the previous and new props, you could do something like this : 93 | 94 | ```tsx 95 | import * as React from 'react' 96 | 97 | import ScrollableFeed from 'react-scrollable-feed' 98 | 99 | class App extends React.Component { 100 | changeDetectionFilter(previousProps, newProps) { 101 | const prevChildren = previousProps.children; 102 | const newChildren = newProps.children; 103 | 104 | return prevChildren !== newChildren 105 | && prevChildren[prevChildren.length - 1] !== newChildren[newChildren.length - 1]; 106 | } 107 | 108 | render() { 109 | const items = ['Item 1', 'Item 2']; 110 | 111 | return ( 112 | 115 | {items.map((item, i) =>
{item}
)} 116 |
117 | ); 118 | } 119 | } 120 | 121 | export default App; 122 | ``` 123 | 124 | ### className 125 | 126 | - Type: `string` 127 | - Default: `undefined` 128 | 129 | `CSS` class that can be added on the wrapping div created by `ScrollableFeed`. 130 | 131 | ### viewableDetectionEpsilon 132 | 133 | - Type: `number` 134 | - Default: `2` 135 | 136 | Indicates the number of pixels of difference between the actual bottom and the current position that can be tolerated. The default setting should be fine for most use cases. 137 | 138 | ### onScroll 139 | 140 | - Type: `(isAtBottom: boolean) => void` 141 | - Default: `() => {}` 142 | 143 | Is called when the `onScroll` event is triggered on the wrapper div created by `ScrollableFeed`. 144 | 145 | Provides `isAtBottom` boolean value as a parameter, which indicates if the scroll is at bottom position, taking `viewableDetectionEpsilon` into account. 146 | 147 | ## Public Methods 148 | 149 | ### scrollToBottom 150 | 151 | - Signature: `() => void` 152 | 153 | Scroll to the bottom 154 | 155 | ## For more details 156 | 157 | For more details on how to integrate _react-scrollable-feed_ in your application, have a look at the [example](example) folder. 158 | 159 | ## Contibuting 160 | - Star this GitHub repo :star: 161 | - Create pull requests, submit bugs, suggest new features or documentation updates :wrench:. See [contributing doc](CONTRIBUTING.md). 162 | 163 | ## License 164 | 165 | BSD 3-Clause © [Gabriel Bourgault](https://github.com/dizco) 166 | 167 | See [license](LICENSE). 168 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | baseUrl: 'http://localhost:3000', 9 | projectId: 'eyny7g', 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/e2e/spec.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Demo page', () => { 2 | beforeEach(() => { 3 | cy.visit('/'); 4 | cy.visit('/'); 5 | }); 6 | 7 | it('successfully loads', () => { 8 | cy.get('.scrollable-wrapper li', { timeout: 10000 }) 9 | .should('have.length', 4); 10 | }); 11 | 12 | it('adds new elements', () => { 13 | cy.contains('Add Item').click(); 14 | 15 | cy.get('.scrollable-wrapper li') 16 | .should('have.length', 5); 17 | }); 18 | 19 | it('scrolls automatically', () => { 20 | cy.contains('Add Item') 21 | .click() 22 | .click() 23 | .click() 24 | .click(); 25 | 26 | cy.get('.scrollable-wrapper li') 27 | .last() 28 | .should('be.visible'); 29 | cy.get('.scrollable-wrapper li') 30 | .first() 31 | .should('not.be.visible'); 32 | }); 33 | 34 | it('force scrolls', () => { 35 | cy.contains('Add Item') 36 | .click() 37 | .click() 38 | .click() 39 | .click(); 40 | 41 | cy.get('.scrollable-wrapper li') 42 | .first() 43 | .scrollIntoView(); 44 | cy.get('.scrollable-wrapper li') 45 | .last() 46 | .should('not.be.visible'); 47 | 48 | cy.contains('Scroll to Bottom') 49 | .click(); 50 | cy.get('.scrollable-wrapper li') 51 | .last() 52 | .should('be.visible'); 53 | }); 54 | }) 55 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "files": [ 4 | "**/*.ts", 5 | "../cypress.config.ts" 6 | ], 7 | "compilerOptions": { 8 | "sourceMap": false, 9 | "types": ["cypress", "chai"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dizco/react-scrollable-feed/8fbe8188f781fd62bc58a37d0121ccc99f4c7e34/docs/demo.gif -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scrollable-feed-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.69", 11 | "@types/react": "^18.2.46", 12 | "bootstrap": "^5.3.2", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-scripts": "5.0.1", 16 | "react-scrollable-feed": "file:..", 17 | "typescript": "^4.9.5", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "start:silent": "npm start >/dev/null", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@types/react-dom": "^18.2.18" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dizco/react-scrollable-feed/8fbe8188f781fd62bc58a37d0121ccc99f4c7e34/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dizco/react-scrollable-feed/8fbe8188f781fd62bc58a37d0121ccc99f4c7e34/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dizco/react-scrollable-feed/8fbe8188f781fd62bc58a37d0121ccc99f4c7e34/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-scrollable-feed", 3 | "name": "react-scrollable-feed", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import ScrollableFeed from 'react-scrollable-feed'; 4 | import { RandomColorGenerator } from './random-color-generator'; 5 | 6 | type Props = {}; 7 | type State = { 8 | isAtBottom: boolean, 9 | items: { 10 | timestamp: string, 11 | color: string, 12 | }[], 13 | interval?: NodeJS.Timer, 14 | } 15 | 16 | export default class App extends Component { 17 | 18 | static intervalDelay = 800; 19 | 20 | private readonly scrollableRef: React.RefObject; 21 | 22 | constructor(props: Props) { 23 | super(props); 24 | 25 | this.scrollableRef = React.createRef(); 26 | } 27 | 28 | state: State = { 29 | isAtBottom: true, 30 | items: [ 31 | this.createItem(), 32 | this.createItem(), 33 | this.createItem(), 34 | this.createItem(), 35 | ], 36 | interval: undefined, 37 | }; 38 | 39 | updateIsAtBottomState(result: any) { 40 | this.setState({ 41 | isAtBottom: result 42 | }); 43 | } 44 | 45 | createItem() { 46 | return { 47 | timestamp: new Date().toISOString(), 48 | color: RandomColorGenerator.get(), 49 | } 50 | } 51 | 52 | addItem() { 53 | this.setState(prevState => ({ 54 | items: [...prevState.items, this.createItem()] 55 | })); 56 | } 57 | 58 | pause() { 59 | clearInterval(this.state.interval); 60 | this.setState(_ => ({ 61 | interval: undefined 62 | })); 63 | } 64 | 65 | resume() { 66 | const interval = setInterval(() => { 67 | this.addItem(); 68 | }, App.intervalDelay); 69 | this.setState(_ => ({ 70 | interval 71 | })); 72 | } 73 | 74 | clear() { 75 | this.setState(_ => ({ 76 | items: [] 77 | })); 78 | } 79 | 80 | scrollToBottom() { 81 | this.scrollableRef.current.scrollToBottom(); 82 | } 83 | 84 | render() { 85 | const { isAtBottom, items, interval } = this.state; 86 | return ( 87 |
88 |
89 |
90 |
91 |
92 | this.updateIsAtBottomState(isAtBottom)} 96 | > 97 |
    98 | {items.map((item, i) => ( 99 |
  • 100 | {item.timestamp} 101 |
  • 102 | ))} 103 |
104 |
105 |
106 |
107 |

{items.length} items

108 | 109 | {interval ? ( 110 | 111 | ) : ( 112 | 113 | )} 114 | 115 | 116 |
117 |
118 |
119 |
120 |
121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | .scrollable-wrapper { 8 | height: 280px; 9 | } 10 | 11 | .dot { 12 | height: 10px; 13 | width: 10px; 14 | border-radius: 50%; 15 | display: inline-block; 16 | } 17 | 18 | .text-center { 19 | text-align: center; 20 | } 21 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import 'bootstrap/dist/css/bootstrap.min.css'; 5 | import App from './App'; 6 | import reportWebVitals from './reportWebVitals'; 7 | 8 | const root = ReactDOM.createRoot( 9 | document.getElementById('root') as HTMLElement 10 | ); 11 | root.render( 12 | 13 | 14 | 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/random-color-generator.ts: -------------------------------------------------------------------------------- 1 | export class RandomColorGenerator { 2 | //See https://sashat.me/2017/01/11/list-of-20-simple-distinct-colors/ 3 | static colors = ['#e6194B', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#42d4f4', '#f032e6', '#fabebe', '#469990', '#e6beff', '#9A6324', '#fffac8', '#800000', '#aaffc3', '#000075', '#a9a9a9', '#000000']; 4 | 5 | static get() { 6 | const index = Math.floor(Math.random() * RandomColorGenerator.colors.length); 7 | return RandomColorGenerator.colors[index]; 8 | } 9 | } -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scrollable-feed", 3 | "version": "2.0.2", 4 | "description": "", 5 | "homepage": "https://dizco.github.io/react-scrollable-feed/", 6 | "type": "module", 7 | "source": "src/index.tsx", 8 | "main": "dist/index.cjs", 9 | "module": "dist/index.module.mjs", 10 | "unpkg": "dist/index.umd.js", 11 | "types": "dist/index.d.ts", 12 | "exports": { 13 | "types": "./dist/index.d.ts", 14 | "require": "./dist/index.cjs", 15 | "default": "./dist/index.modern.mjs" 16 | }, 17 | "scripts": { 18 | "dev": "microbundle watch --jsx React.createElement --jsxFragment React.Fragment --jsxImportSource react --css inline", 19 | "build": "microbundle --jsx React.createElement --jsxFragment React.Fragment --jsxImportSource react --css inline", 20 | "prepare": "npm run build", 21 | "cypress:open": "cross-env CYPRESS_CRASH_REPORTS=0 cypress open", 22 | "cypress:run": "cross-env CYPRESS_CRASH_REPORTS=0 cypress run" 23 | }, 24 | "author": "dizco", 25 | "license": "MIT", 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/dizco/react-scrollable-feed.git" 29 | }, 30 | "peerDependencies": { 31 | "prop-types": "^15.7.2", 32 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", 33 | "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 34 | }, 35 | "devDependencies": { 36 | "@types/react": "^18.2.46", 37 | "cross-env": "^7.0.3", 38 | "cypress": "^13.6.2", 39 | "microbundle": "^0.15.1" 40 | }, 41 | "files": [ 42 | "dist" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>kiosoft/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CSSProperties } from 'react'; 3 | 4 | export type ScrollableFeedProps = { 5 | forceScroll?: boolean; 6 | animateScroll?: (element: HTMLElement, offset: number) => void; 7 | onScrollComplete?: () => void; 8 | changeDetectionFilter?: (previousProps: ScrollableFeedComponentProps, newProps: ScrollableFeedComponentProps) => boolean; 9 | viewableDetectionEpsilon?: number; 10 | className?: string; 11 | onScroll?: (isAtBottom: boolean) => void; 12 | debug?: boolean 13 | } 14 | 15 | type ScrollableFeedComponentProps = React.PropsWithChildren; 16 | 17 | class ScrollableFeed extends React.Component> { 18 | private readonly wrapperRef: React.RefObject; 19 | private readonly bottomRef: React.RefObject; 20 | 21 | constructor(props: ScrollableFeedProps) { 22 | super(props); 23 | this.bottomRef = React.createRef(); 24 | this.wrapperRef = React.createRef(); 25 | this.handleScroll = this.handleScroll.bind(this); 26 | 27 | if (this.props.debug) console.log("Component cstr"); 28 | } 29 | 30 | static defaultProps: ScrollableFeedProps = { 31 | forceScroll: false, 32 | animateScroll: (element: HTMLElement, offset: number): void => { 33 | if (element.scrollBy) { 34 | element.scrollBy({ top: offset }); 35 | } 36 | else { 37 | element.scrollTop = offset; 38 | } 39 | }, 40 | onScrollComplete: () => {}, 41 | changeDetectionFilter: () => true, 42 | viewableDetectionEpsilon: 2, 43 | onScroll: () => {}, 44 | }; 45 | 46 | getSnapshotBeforeUpdate(): boolean { 47 | if (this.props.debug) console.log("Component ", this.getSnapshotBeforeUpdate.name); 48 | if (this.wrapperRef.current && this.bottomRef.current) { 49 | const { viewableDetectionEpsilon } = this.props; 50 | return ScrollableFeed.isViewable(this.wrapperRef.current, this.bottomRef.current, viewableDetectionEpsilon!); //This argument is passed down to componentDidUpdate as 3rd parameter 51 | } 52 | return false; 53 | } 54 | 55 | componentDidUpdate(previousProps: ScrollableFeedComponentProps, _previousState: any, snapshot: boolean): void { 56 | if (this.props.debug) console.log("Component ", this.componentDidUpdate.name); 57 | const { forceScroll, changeDetectionFilter } = this.props; 58 | const isValidChange = changeDetectionFilter!(previousProps, this.props); 59 | if (isValidChange && (forceScroll || snapshot) && this.bottomRef.current && this.wrapperRef.current) { 60 | this.scrollParentToChild(this.wrapperRef.current, this.bottomRef.current); 61 | } 62 | } 63 | 64 | componentDidMount(): void { 65 | if (this.props.debug) console.log("Component ", this.componentDidMount.name); 66 | //Scroll to bottom from the start 67 | if (this.bottomRef.current && this.wrapperRef.current) { 68 | this.scrollParentToChild(this.wrapperRef.current, this.bottomRef.current); 69 | } 70 | } 71 | 72 | /** 73 | * Scrolls a parent element such that the child element will be in view 74 | * @param parent 75 | * @param child 76 | */ 77 | protected scrollParentToChild(parent: HTMLElement, child: HTMLElement): void { 78 | const { viewableDetectionEpsilon } = this.props; 79 | if (!ScrollableFeed.isViewable(parent, child, viewableDetectionEpsilon!)) { 80 | //Source: https://stackoverflow.com/a/45411081/6316091 81 | const parentRect = parent.getBoundingClientRect(); 82 | const childRect = child.getBoundingClientRect(); 83 | 84 | //Scroll by offset relative to parent 85 | const scrollOffset = (childRect.top + parent.scrollTop) - parentRect.top; 86 | const { animateScroll, onScrollComplete } = this.props; 87 | if (animateScroll) { 88 | animateScroll(parent, scrollOffset); 89 | onScrollComplete!(); 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * Returns whether a child element is visible within a parent element 96 | * 97 | * @param parent 98 | * @param child 99 | * @param epsilon 100 | */ 101 | private static isViewable(parent: HTMLElement, child: HTMLElement, epsilon: number): boolean { 102 | epsilon = epsilon || 0; 103 | 104 | //Source: https://stackoverflow.com/a/45411081/6316091 105 | const parentRect = parent.getBoundingClientRect(); 106 | const childRect = child.getBoundingClientRect(); 107 | 108 | const childTopIsViewable = (childRect.top >= parentRect.top); 109 | 110 | const childOffsetToParentBottom = parentRect.top + parent.clientHeight - childRect.top; 111 | const childBottomIsViewable = childOffsetToParentBottom + epsilon >= 0; 112 | 113 | return childTopIsViewable && childBottomIsViewable; 114 | } 115 | 116 | /** 117 | * Fires the onScroll event, sending isAtBottom boolean as its first parameter 118 | */ 119 | protected handleScroll(): void { 120 | const { viewableDetectionEpsilon, onScroll } = this.props; 121 | if (onScroll && this.bottomRef.current && this.wrapperRef.current) { 122 | const isAtBottom = ScrollableFeed.isViewable(this.wrapperRef.current, this.bottomRef.current, viewableDetectionEpsilon!); 123 | onScroll(isAtBottom); 124 | } 125 | } 126 | 127 | /** 128 | * Scroll to the bottom 129 | */ 130 | public scrollToBottom(): void { 131 | if (this.bottomRef.current && this.wrapperRef.current) { 132 | this.scrollParentToChild(this.wrapperRef.current, this.bottomRef.current); 133 | } 134 | } 135 | 136 | render(): React.ReactNode { 137 | if (this.props.debug) console.log("Component ", this.render.name); 138 | 139 | const style: CSSProperties = { 140 | maxHeight: "inherit", 141 | height: "inherit", 142 | overflowY: "auto", 143 | }; 144 | const { children, className } = this.props; 145 | return ( 146 |
147 | {children} 148 |
149 |
150 | ); 151 | } 152 | } 153 | 154 | export default ScrollableFeed; 155 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["node_modules", "build", "dist", "example"] 23 | } 24 | --------------------------------------------------------------------------------