├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── greenkeeper.json ├── package.json ├── packages ├── react-delightful-scroller │ ├── .babelrc │ ├── .eslintrc │ ├── .gitignore │ ├── .npmignore │ ├── package.json │ ├── rollup.config.js │ └── src │ │ ├── BatchRenderer.js │ │ ├── DefaultRenderers.js │ │ ├── RenderItemWrapper.js │ │ ├── Sentinel.js │ │ ├── Wrapper.js │ │ ├── getBatchedItems.js │ │ ├── getVisibleIndexes.js │ │ ├── index.js │ │ ├── initializeDimensions.js │ │ ├── initializeInitialVisibility.js │ │ ├── useDimensions.js │ │ ├── useScroll.js │ │ ├── useVisibility.js │ │ └── useVisibilityAndDimension.js └── storybook │ ├── .eslintrc │ ├── .gitignore │ ├── .storybook │ ├── addons.js │ ├── config.js │ ├── manager-head.html │ ├── preview-head.html │ └── webpack.config.js │ ├── _headers │ ├── babel-plugin-macros.config.js │ ├── package.json │ └── stories │ ├── base-components │ ├── BaseDefaultStory.js │ ├── BaseDynamicHeightInfinite.js │ ├── BaseDynamicHeightInfiniteAnimated.js │ └── BaseFixedHeightInfinite.js │ ├── custom-container │ ├── default.stories.js │ ├── infinite-dynamic-animatable.stories.js │ ├── infinite-dynamic.stories.js │ └── infinite-fixed.stories.js │ ├── shared │ ├── Card.js │ ├── Container.js │ ├── CustomScrollContainer.js │ ├── DetectUnmount.js │ ├── RenderContainer.js │ ├── RenderItem.js │ ├── RenderLoader.js │ ├── Warning.js │ └── base.js │ ├── utils │ └── helpers.js │ └── window-scroll │ ├── default.stories.js │ ├── infinite-dynamic-animatable.stories.js │ ├── infinite-dynamic.stories.js │ └── infinite-fixed.stories.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Misc 64 | *.swp 65 | *~ 66 | *.iml 67 | .*.haste_cache.* 68 | .DS_Store 69 | .idea 70 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | os: 4 | - linux 5 | - osx 6 | node_js: 7 | - "node" 8 | - "10" 9 | # - "9" 10 | - "8" 11 | # - "7" - Unsupported(https://github.com/nodejs/Release#end-of-life-releases) 12 | # - "6" 13 | cache: 14 | yarn: true 15 | directories: 16 | - node_modules 17 | script: 18 | - yarn build 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Ganapati V S 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to react-delightful-scroller 👋 2 | 3 |

4 | Version 5 | 6 | Greenkeeper badge 7 | 8 | 9 | Documentation 10 | 11 | 12 | Maintenance 13 | 14 | 15 | License: MIT 16 | 17 | 18 | Netlify Status 19 | 20 | 21 | Twitter: ganapativs 22 | 23 |

24 | 25 | A delightful, virtualized modern infinite scroller 🎉 26 | 27 | Find demos and more usage examples at **[react-delightful-scroller.netlify.com](https://react-delightful-scroller.netlify.com/)** 28 | 29 | ```jsx 30 | // Basic usage 31 | import React from "react"; 32 | import DelightfulScroller from "react-delightful-scroller"; 33 | 34 | const items = Array.from({ length: 1000 }) 35 | .fill(true) 36 | .map((_, i) => i + 1); 37 | 38 | const VirtualizedItems = () => ( 39 |
{item}
} 42 | itemsCount={items.length} 43 | averageItemHeight={10} 44 | /> 45 | ); 46 | ``` 47 | 48 | [![Edit sleepy-star-o0jzz](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/sleepy-star-o0jzz?fontsize=12) 49 | 50 | ## Features 51 | 52 | - Provides delightful infinite scrolling experience for thousands of items 53 | - Uses batching and virtualization techniques to reduce DOM nodes 54 | - Handles fixed and dynamic height elements automatically 55 | - Smooth, aims to achieve 60fps 56 | - Fully customizable 57 | - Uses modern web APIs/features 58 | - Supports vertical scrolling on window (More axis and custom element scroll support soon) 59 | - Tiny library(Around **7kb gzip**) 60 | - Many more 61 | 62 | ## Component props 63 | 64 | | Property | Type | Required? | Description | 65 | |:---|:---|:---:|:---| 66 | | items | Array | ✓ | Items to render, can be array of strings, objects or numbers etc. | 67 | | itemsCount | Number | ✓ | Total number of items to render, this can be larger than number of `items`, in that case a sentinel is added at the bottom(infinite scroll) of rendered items and `onFetchMore` is triggered when page is scrolled to bottom until the number of `items` are equal to the `itemsCount`. | 68 | | RenderItem | Component | ✓ | Component which renders each item. Gets `item` and `index` as prop. | 69 | | RenderContainer | Component | | Component which renders scroll container. Gets `children` and `forwardRef` as prop. | 70 | | removeFromDOM | Boolean | | Should remove/add items from DOM while virtualizing and replace with empty node of same height of item. If set to `false`, items will be set to `visibility: hidden;` if not visible in the viewport. Default: `true`. | 71 | | root | Function | | A function returning scroll parent node. Scroll listener will be attached to this element(if provided) instead of `window`. Default: `null`(indicates `window`/viewport is the scroll parent). | 72 | | averageItemHeight | Number | | Average item height if the items are dynamic. Default: `10`. | 73 | | itemHeight | Number | | Fixed item height if the items height is fixed. This takes more priority over `averageItemHeight` if specified. Default: `null`. | 74 | | getItemKey | Function | | Specify custom `key` prop while rendering each item. function receives `item` and `index` as argument. | 75 | | wrapperElement | String | | HTML tag used to wrap each rendered item and sentinel. Default: `div`. | 76 | | axis | String | | Scroll axis. Default: `y`. | 77 | | batch | Boolean | | Should batch items(check `batchSize`) or not. Default: `true`. | 78 | | batchSize | Number | | Batch of items to render at once when virtualizing. Default: `10`. | 79 | | batchBufferDistance | Number | | Buffer distance of batch from both side of viewport/scrollable node. the batch is only rendered if the batch overlaps with this offset. Default: `250`. | 80 | | fetchMoreBufferDistance | Number | | Buffer distance to trigger `onFetchMore`. Default: `500`. | 81 | | onFetchMore | Function | | Function called when sentinel is near the viewport(when it crosses `fetchMoreBufferDistance`). The function receives `{size, itemsCount, batchSize}` as argument. | 82 | | RenderLoader | Component | | Component which renders loader when sentinel triggers `onFetchMore`(basically, more items are loading). Gets `items`, `itemsCount` and `batchSize` as prop. | 83 | 84 | ## Install 85 | 86 | This project uses yarn workspaces. You need to have `yarn` installed. 87 | 88 | ```sh 89 | yarn 90 | ``` 91 | 92 | ## Usage 93 | 94 | ```sh 95 | # Develop 96 | yarn watch 97 | ``` 98 | 99 | ```sh 100 | # Build and serve 101 | yarn build 102 | yarn serve 103 | ``` 104 | 105 | ## TODO 106 | 107 | - [x] Custom container support 108 | - [ ] Support more axis - x, y-reverse & x-reverse 109 | - [ ] Scroll restoration 110 | - [ ] More stories 111 | - [ ] Test cases 112 | 113 | ## Author 114 | 115 | 👤 **Ganapati V S [](meetguns.com)** 116 | 117 | * Twitter: [@ganapativs](https://twitter.com/ganapativs) 118 | * Github: [@ganapativs](https://github.com/ganapativs) 119 | 120 | ## 🤝 Contributing 121 | 122 | Contributions, issues and feature requests are welcome!
Feel free to check [issues page](https://github.com/ganapativs/react-delightful-scroller/issues). 123 | 124 | ## Show your support 125 | 126 | Give a ⭐️ if this project helped you! 127 | 128 | ## 📝 License 129 | 130 | Copyright © 2019 [Ganapati V S ](https://github.com/ganapativs).
131 | This project is [MIT](https://github.com/ganapativs/react-delightful-scroller/blob/master/LICENSE) licensed. 132 | -------------------------------------------------------------------------------- /greenkeeper.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | "default": { 4 | "packages": [ 5 | "package.json", 6 | "packages/react-delightful-scroller/package.json", 7 | "packages/storybook/package.json" 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-delightful-scroller-monorepo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "source": "packages/react-delightful-scroller/src/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/ganapativs/react-delightful-scroller.git" 12 | }, 13 | "author": "Ganapati V S ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/ganapativs/react-delightful-scroller/issues" 17 | }, 18 | "homepage": "https://github.com/ganapativs/react-delightful-scroller", 19 | "scripts": { 20 | "watch-rds": "cd packages/react-delightful-scroller; yarn watch", 21 | "build-rds": "cd packages/react-delightful-scroller; yarn build; yarn size", 22 | "lint-rds": "cd packages/react-delightful-scroller; yarn lint", 23 | "watch-rds-storybook": "cd packages/storybook; yarn storybook", 24 | "build-rds-storybook": "cd packages/storybook; yarn build-storybook", 25 | "lint-rds-storybook": "cd packages/storybook; yarn lint", 26 | "watch": "run-p watch-rds watch-rds-storybook", 27 | "build": "run-s build-rds build-rds-storybook", 28 | "lint": "run-s lint-rds lint-rds-storybook", 29 | "serve": "cd packages/storybook/build; serve" 30 | }, 31 | "dependencies": { 32 | "npm-run-all": "^4.1.5" 33 | }, 34 | "devDependencies": { 35 | "husky": "^3.0.1", 36 | "serve": "^11.1.0" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "yarn lint" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@ganapativs/babel-preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ganapativs/react", 3 | "rules": { 4 | "import/prefer-default-export": 0, 5 | "react/prop-types" : 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/.npmignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *~ 3 | *.iml 4 | .*.haste_cache.* 5 | .DS_Store 6 | .idea 7 | .babelrc 8 | .eslintrc 9 | npm-debug.log 10 | yarn-error.log 11 | src 12 | demo 13 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-delightful-scroller", 3 | "version": "0.1.3", 4 | "description": "Delightful, virtualized modern infinite scroller 🎉", 5 | "source": "src/index.js", 6 | "main": "dist/react-delightful-scroller.js", 7 | "module": "dist/react-delightful-scroller.es.js", 8 | "jsnext:main": "dist/react-delightful-scroller.es.js", 9 | "umd:main": "dist/react-delightful-scroller.umd.js", 10 | "unpkg": "dist/react-delightful-scroller.umd.js", 11 | "files": [ 12 | "dist" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/ganapativs/react-delightful-scroller.git" 17 | }, 18 | "author": "Ganapati V S ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/ganapativs/react-delightful-scroller/issues" 22 | }, 23 | "homepage": "https://github.com/ganapativs/react-delightful-scroller", 24 | "keywords": [ 25 | "react component", 26 | "performance", 27 | "modern", 28 | "virtualized", 29 | "scroller", 30 | "infinite-scroller", 31 | "optimization", 32 | "intersection-observer", 33 | "resize-observer", 34 | "requestAnimationFrame", 35 | "60fps" 36 | ], 37 | "scripts": { 38 | "prebuild": "rimraf dist && mkdirp dist", 39 | "build": "NODE_ENV=production rollup -c", 40 | "watch": "NODE_ENV=development rollup -c -w", 41 | "lint": "eslint './src/**/*' --quiet --fix", 42 | "prepublishOnly": "yarn build && cp ../../README.md .", 43 | "postpublish": "rm ./README.md", 44 | "size": "size-limit" 45 | }, 46 | "size-limit": [ 47 | { 48 | "limit": "10 KB", 49 | "path": "dist/react-delightful-scroller.js" 50 | } 51 | ], 52 | "devDependencies": { 53 | "@ganapativs/babel-preset-react": "^0.0.2", 54 | "@ganapativs/eslint-config-react": "^0.0.3", 55 | "@size-limit/preset-big-lib": "^2.1.1", 56 | "mkdirp": "^0.5.1", 57 | "rimraf": "^3.0.0", 58 | "rollup": "^1.16.7", 59 | "rollup-plugin-babel": "^4.3.3", 60 | "rollup-plugin-commonjs": "^10.0.1", 61 | "rollup-plugin-eslint": "^7.0.0", 62 | "rollup-plugin-node-resolve": "^5.2.0", 63 | "rollup-plugin-peer-deps-external": "^2.2.0", 64 | "rollup-plugin-terser": "^5.1.1", 65 | "rollup-plugin-uglify": "^6.0.2" 66 | }, 67 | "peerDependencies": { 68 | "react": "^15.0.0 || ^16.0.0" 69 | }, 70 | "dependencies": { 71 | "@rehooks/component-size": "^1.0.3", 72 | "@rehooks/window-size": "^1.0.2", 73 | "lodash.throttle": "^4.1.1", 74 | "prop-types": "^15.7.2", 75 | "react": "^16.8.6", 76 | "react-measure": "^2.3.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import resolve from "rollup-plugin-node-resolve"; 4 | import external from "rollup-plugin-peer-deps-external"; 5 | import { terser } from "rollup-plugin-terser"; 6 | import { eslint } from "rollup-plugin-eslint"; 7 | import pkg from "./package.json"; 8 | 9 | // eslint-disable-next-line no-undef 10 | const { NODE_ENV } = process.env; 11 | const isDev = NODE_ENV === "development"; 12 | 13 | const getPlugins = () => [ 14 | eslint(), 15 | external(), 16 | resolve(), 17 | babel({ 18 | comments: true, 19 | exclude: "node_modules/**" 20 | }), 21 | commonjs() 22 | ]; 23 | 24 | export default [ 25 | !isDev && { 26 | input: "src/index.js", 27 | output: [ 28 | { 29 | file: pkg.main, 30 | format: "cjs", 31 | sourcemap: true 32 | } 33 | ], 34 | external: [...Object.keys(pkg.dependencies || {})], 35 | plugins: getPlugins().concat([terser()]) 36 | }, 37 | // Built in both dev and prod 38 | { 39 | input: "src/index.js", 40 | output: [ 41 | { 42 | file: pkg.module, 43 | format: "es", 44 | sourcemap: true 45 | } 46 | ], 47 | external: [...Object.keys(pkg.dependencies || {})], 48 | plugins: getPlugins() 49 | }, 50 | !isDev && { 51 | input: "src/index.js", 52 | output: { 53 | globals: { 54 | react: "React" 55 | }, 56 | name: "ReactDelightfulScroller", 57 | file: pkg["umd:main"], 58 | format: "umd", 59 | sourcemap: true 60 | }, 61 | plugins: getPlugins().concat([terser()]) 62 | } 63 | ].filter(Boolean); 64 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/BatchRenderer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Measure from "react-measure"; 3 | import { Wrapper } from "./Wrapper"; 4 | import { RenderItemWrapper } from "./RenderItemWrapper"; 5 | 6 | export const BatchRenderer = React.memo( 7 | ({ 8 | batch, 9 | index, 10 | getItemKey, 11 | batchSize, 12 | wrapperElement, 13 | removeFromDOM, 14 | dimensions, 15 | setDimension, 16 | RenderItem, 17 | visible, 18 | itemHeight 19 | }) => { 20 | const hasFixedHeightItems = !!itemHeight; 21 | let batchWrapper = null; 22 | 23 | if (visible || !removeFromDOM) { 24 | const items = batch.map((item, idx) => { 25 | const actualIndex = batchSize * index + idx; 26 | const key = getItemKey(item, actualIndex); 27 | return ( 28 | 34 | ); 35 | }); 36 | 37 | const itemsBatch = ( 38 | 45 | {items} 46 | 47 | ); 48 | 49 | batchWrapper = hasFixedHeightItems ? ( 50 | // No need to add resize observer to batch of fixed height items 51 | itemsBatch 52 | ) : ( 53 | // Add resize observer to batch of dynamic items 54 | { 58 | setDimension(index, contentRect); 59 | }} 60 | > 61 | {({ measureRef }) => 62 | React.cloneElement(itemsBatch, { ref: measureRef }) 63 | } 64 | 65 | ); 66 | } else { 67 | batchWrapper = ( 68 |
73 | ); 74 | } 75 | 76 | return batchWrapper; 77 | } 78 | // Don't put equality check for batch items here! 79 | // prev batch items changes are reverted if next batch items are changed 80 | // Might create memory leak/closure issues in react hooks 81 | ); 82 | 83 | BatchRenderer.displayName = "BatchRenderer"; 84 | 85 | export const NoRemoveFromDOMBatcher = React.memo( 86 | props => , 87 | ( 88 | { batch: prevBatch, visible: prevVisible }, 89 | { batch, visible, removeFromDOM } 90 | ) => { 91 | if (!removeFromDOM) { 92 | const batchItemsHaveSameRef = 93 | prevBatch.length === batch.length && 94 | prevBatch.every((e, i) => e === batch[i]); 95 | return ( 96 | batchItemsHaveSameRef && 97 | prevVisible === visible && 98 | (prevVisible === false && visible === false) 99 | ); 100 | } 101 | 102 | return true; 103 | } 104 | ); 105 | 106 | NoRemoveFromDOMBatcher.displayName = "NoRemoveFromDOMBatcher"; 107 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/DefaultRenderers.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DefaultRenderContainer = ({ children, forwardRef }) => ( 4 |
{children}
5 | ); 6 | 7 | DefaultRenderContainer.displayName = "DefaultRenderContainer"; 8 | 9 | // eslint-disable-next-line no-unused-vars 10 | export const DefaultRenderItem = ({ item, index }) => item; 11 | 12 | DefaultRenderItem.displayName = "DefaultRenderItem"; 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | export const DefaultRenderLoader = ({ items, itemsCount, batchSize }) => null; 16 | 17 | DefaultRenderLoader.displayName = "DefaultRenderLoader"; 18 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/RenderItemWrapper.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Don't put equality check for items here! 4 | // node won't update when other props on Render item changes 5 | // Might create memory leak/closure issues in react hooks 6 | export const RenderItemWrapper = React.memo(({ item, index, RenderItem }) => ( 7 | 8 | )); 9 | 10 | RenderItemWrapper.displayName = "RenderItemWrapper"; 11 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/Sentinel.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | 3 | export const Sentinel = ({ 4 | fetchMoreBufferDistance, 5 | onFetchMore, 6 | RenderLoader, 7 | wrapperElement, 8 | items, 9 | itemsCount, 10 | batchSize, 11 | root, 12 | axis 13 | }) => { 14 | const ref = useRef(null); 15 | 16 | useEffect(() => { 17 | const targetNode = ref.current; 18 | const options = { 19 | root: root ? root() : null, 20 | rootMargin: 21 | axis === "y" ? `0px 0px ${fetchMoreBufferDistance}px 0px` : "0px", 22 | threshold: 0 23 | }; 24 | const callback = ([{ isIntersecting }]) => { 25 | if (isIntersecting) { 26 | onFetchMore({ size: items.length, itemsCount, batchSize }); 27 | } 28 | }; 29 | const observer = new IntersectionObserver(callback, options); 30 | if (targetNode) { 31 | observer.observe(targetNode); 32 | } 33 | 34 | return () => { 35 | // Stop watching all of its target elements for visibility changes 36 | observer.disconnect(); 37 | }; 38 | }, [ 39 | axis, 40 | fetchMoreBufferDistance, 41 | batchSize, 42 | items, 43 | onFetchMore, 44 | root, 45 | itemsCount 46 | ]); 47 | 48 | return React.createElement( 49 | wrapperElement, 50 | { 51 | ref 52 | }, 53 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/Wrapper.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Wrapper = React.forwardRef( 4 | ({ as = "div", style, children }, ref) => 5 | React.createElement(as, { ref, style }, children) 6 | ); 7 | 8 | Wrapper.displayName = "Wrapper"; 9 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/getBatchedItems.js: -------------------------------------------------------------------------------- 1 | export const getBatchedItems = (items, batchSize = 1) => { 2 | const batched = []; 3 | 4 | // Faster than clone and splice 5 | for (let index = 0; index < items.length; index += batchSize) { 6 | const chunk = items.slice(index, index + batchSize); 7 | // Do something if you want with the group 8 | batched.push(chunk); 9 | } 10 | 11 | return batched; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/getVisibleIndexes.js: -------------------------------------------------------------------------------- 1 | export const getVisibleIndexes = visibility => { 2 | let start; 3 | let end; 4 | 5 | for (let i = 0; i < visibility.length; i += 1) { 6 | const v = visibility[i]; 7 | if (start !== undefined && end !== undefined && !v) { 8 | break; 9 | } 10 | if (start === undefined && v) { 11 | start = i; 12 | } 13 | if (start !== undefined && v) { 14 | end = i; 15 | } 16 | } 17 | 18 | return [start, end]; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: 3 | * - Use scrollRestoration to reduce batch creation - https://itsze.ro/blog/2017/04/09/infinite-list-and-react.html 4 | * - Scroll restoration 5 | * - Optimize computations 6 | */ 7 | import React, { memo, useState, useEffect } from "react"; 8 | import useWindowSize from "@rehooks/window-size"; 9 | import useComponentSize from "@rehooks/component-size"; 10 | import PropTypes from "prop-types"; 11 | import { getBatchedItems } from "./getBatchedItems"; 12 | import { BatchRenderer, NoRemoveFromDOMBatcher } from "./BatchRenderer"; 13 | import { useVisibilityAndDimension } from "./useVisibilityAndDimension"; 14 | import { getVisibleIndexes } from "./getVisibleIndexes"; 15 | import { Sentinel } from "./Sentinel"; 16 | import { 17 | DefaultRenderItem, 18 | DefaultRenderContainer, 19 | DefaultRenderLoader 20 | } from "./DefaultRenderers"; 21 | 22 | const BaseRenderer = ({ 23 | containerHeight, 24 | items, 25 | RenderItem, 26 | getItemKey, 27 | wrapperElement, 28 | forwardRef, 29 | RenderContainer, 30 | removeFromDOM, 31 | root, 32 | batch, 33 | batchSize, 34 | axis, 35 | averageItemHeight, 36 | itemHeight, 37 | itemsCount, 38 | batchBufferDistance, 39 | onFetchMore, 40 | RenderLoader, 41 | fetchMoreBufferDistance 42 | }) => { 43 | const [dimensions, visibility, setDimension] = useVisibilityAndDimension({ 44 | root, 45 | axis, 46 | containerHeight, 47 | itemsCount, 48 | itemHeight, 49 | averageItemHeight, 50 | batchSize, 51 | batchBufferDistance 52 | }); 53 | 54 | const batchedItems = getBatchedItems(items, batchSize); 55 | const Batcher = removeFromDOM ? BatchRenderer : NoRemoveFromDOMBatcher; 56 | let current = batchedItems; 57 | let previous = []; 58 | let next = []; 59 | let prevHeight; 60 | let nextHeight; 61 | 62 | if (removeFromDOM && batch) { 63 | const [startIndex, endIndex] = getVisibleIndexes(visibility); 64 | previous = batchedItems.slice(0, startIndex); 65 | current = batchedItems.slice(startIndex, endIndex + 1); 66 | next = batchedItems.slice(endIndex + 1, batchedItems.length); 67 | 68 | prevHeight = previous.reduce((p, c, i) => { 69 | const index = i; 70 | const dimension = dimensions[index]; 71 | return p + dimension.height; 72 | }, 0); 73 | 74 | nextHeight = next.reduce((p, c, i) => { 75 | const index = previous.length + current.length + i; 76 | const dimension = dimensions[index]; 77 | return p + dimension.height; 78 | }, 0); 79 | } 80 | 81 | const batchedElements = current.map((currentBatch, i) => { 82 | const index = previous.length + i; 83 | return ( 84 | 98 | ); 99 | }); 100 | 101 | const Container = ( 102 | 103 | {prevHeight ? ( 104 |
105 | ) : null} 106 | {batchedElements} 107 | {nextHeight ? ( 108 |
109 | ) : null} 110 | {axis === "y" && items.length < itemsCount ? ( 111 | 122 | ) : null} 123 | 124 | ); 125 | 126 | return Container; 127 | }; 128 | 129 | BaseRenderer.displayName = "BaseRenderer"; 130 | 131 | const WindowContainer = props => { 132 | const { innerWidth, innerHeight } = useWindowSize(); 133 | 134 | return ( 135 | 140 | ); 141 | }; 142 | 143 | const CustomScrollContainer = props => { 144 | const { root } = props; 145 | const { width, height } = useComponentSize({ current: root() }); 146 | 147 | return ( 148 | 149 | ); 150 | }; 151 | 152 | const Entry = (props, ref) => { 153 | const [render, setRender] = useState(!props.root); 154 | 155 | /** 156 | * Mount custom container after the first render cycle 157 | * to make sure the parent scroll node is available 158 | */ 159 | useEffect(() => { 160 | if (!render) { 161 | setRender(true); 162 | } 163 | }, []); 164 | 165 | if (render) { 166 | // Window scroll 167 | if (!props.root) { 168 | return ; 169 | } 170 | 171 | // Custom container scroll 172 | return ; 173 | } 174 | 175 | return null; 176 | }; 177 | 178 | const DelightfulScroller = memo(React.forwardRef(Entry)); 179 | 180 | DelightfulScroller.defaultProps = { 181 | items: [], 182 | itemsCount: 0, 183 | RenderItem: DefaultRenderItem, 184 | getItemKey: (item, index) => (typeof item === "string" ? item : index), 185 | wrapperElement: "div", 186 | RenderContainer: DefaultRenderContainer, 187 | removeFromDOM: true, 188 | root: null, 189 | averageItemHeight: 10, 190 | itemHeight: null, 191 | axis: "y", 192 | batch: true, 193 | batchSize: 10, 194 | batchBufferDistance: 500, 195 | fetchMoreBufferDistance: 500, 196 | RenderLoader: DefaultRenderLoader, 197 | // eslint-disable-next-line no-unused-vars 198 | onFetchMore: ({ items, itemsCount, batchSize }) => {} 199 | }; 200 | 201 | DelightfulScroller.propTypes = { 202 | items: PropTypes.arrayOf(PropTypes.any), 203 | itemsCount: PropTypes.number, 204 | RenderItem: PropTypes.elementType, 205 | getItemKey: PropTypes.func, 206 | wrapperElement: PropTypes.string, 207 | RenderContainer: PropTypes.elementType, 208 | removeFromDOM: PropTypes.bool, 209 | root: PropTypes.func, 210 | averageItemHeight: PropTypes.number, 211 | itemHeight: PropTypes.number, 212 | axis: PropTypes.oneOf(["y"]), 213 | batch: PropTypes.bool, 214 | batchSize: PropTypes.number, 215 | batchBufferDistance: PropTypes.number, 216 | fetchMoreBufferDistance: PropTypes.number, 217 | RenderLoader: PropTypes.elementType, 218 | onFetchMore: PropTypes.func 219 | }; 220 | 221 | DelightfulScroller.displayName = "DelightfulScroller"; 222 | 223 | export default DelightfulScroller; 224 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/initializeDimensions.js: -------------------------------------------------------------------------------- 1 | export function initializeDimensions({ 2 | axis, 3 | itemHeight, 4 | averageItemHeight, 5 | batchSize, 6 | itemsCount 7 | }) { 8 | return () => { 9 | const totalBatches = Math.ceil(itemsCount / batchSize); 10 | const estimatedEmptyBatchHeight = 11 | axis === "y" 12 | ? Math.ceil((itemHeight || averageItemHeight) * batchSize) 13 | : // TODO - handle other directions 14 | 0; 15 | const initial = []; 16 | for (let i = 0; i < totalBatches; i += 1) { 17 | initial[i] = { height: estimatedEmptyBatchHeight, width: null }; 18 | } 19 | return initial; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/initializeInitialVisibility.js: -------------------------------------------------------------------------------- 1 | export function initializeInitialVisibility({ 2 | axis, 3 | containerHeight, 4 | itemHeight, 5 | averageItemHeight, 6 | batchSize, 7 | itemsCount 8 | }) { 9 | return () => { 10 | const totalBatches = Math.ceil(itemsCount / batchSize); 11 | const estimatedInitialBatches = 12 | axis === "y" 13 | ? Math.ceil( 14 | containerHeight / ((itemHeight || averageItemHeight) * batchSize) 15 | ) 16 | : // TODO - handle other directions 17 | 0; 18 | const initial = []; 19 | for (let i = 0; i < totalBatches; i += 1) { 20 | initial[i] = i < estimatedInitialBatches; 21 | } 22 | return initial; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/useDimensions.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | 3 | export const useDimensions = (initialValue = []) => { 4 | const [dimensions, setDimension] = useState(initialValue); 5 | // Set state is async, we need a ref to store intermediate value 6 | const intermediate = useRef(null); 7 | const wrappedSetDimensions = (index, dimension) => { 8 | const newDimensions = [ 9 | ...((intermediate && intermediate.current) || dimensions) 10 | ]; 11 | const { width, height } = dimension.scroll; 12 | newDimensions[index] = { width, height }; 13 | intermediate.current = newDimensions; 14 | setDimension(newDimensions); 15 | }; 16 | return [dimensions, wrappedSetDimensions]; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/useScroll.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import throttle from "lodash.throttle"; 3 | 4 | const getScrollOffset = (element, axis) => { 5 | const { scrollTop, scrollY } = element; 6 | if (axis === "y") { 7 | if (scrollTop !== undefined) { 8 | return scrollTop; 9 | } 10 | return scrollY; 11 | } 12 | return 0; 13 | }; 14 | 15 | export const useScroll = ({ root, axis }) => { 16 | const timeout = useRef(null); 17 | const [scrollOffset, setScrollOffset] = useState(0); 18 | 19 | useEffect(() => { 20 | const element = root ? root() : window; 21 | const handler = throttle(() => { 22 | // If there's a timer, cancel it 23 | if (timeout.current) { 24 | window.cancelAnimationFrame(timeout.current); 25 | } 26 | // Setup the requestAnimationFrame 27 | timeout.current = window.requestAnimationFrame(() => 28 | setScrollOffset(getScrollOffset(element, axis)) 29 | ); 30 | }, 100); 31 | element.addEventListener("scroll", handler, { 32 | capture: false, 33 | passive: true 34 | }); 35 | 36 | return () => { 37 | if (handler.cancel) { 38 | handler.cancel(); 39 | } 40 | window.cancelAnimationFrame(timeout.current); 41 | element.removeEventListener("scroll", handler); 42 | }; 43 | }, [axis, root]); 44 | 45 | return scrollOffset; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/useVisibility.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export function useVisibility(initial = []) { 4 | const [visibility, setVisibility] = useState(initial); 5 | 6 | const wrappedSetVisibility = newVisibility => { 7 | setVisibility(newVisibility); 8 | }; 9 | 10 | return [visibility, wrappedSetVisibility]; 11 | } 12 | -------------------------------------------------------------------------------- /packages/react-delightful-scroller/src/useVisibilityAndDimension.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDimensions } from "./useDimensions"; 3 | import { initializeDimensions } from "./initializeDimensions"; 4 | import { useVisibility } from "./useVisibility"; 5 | import { initializeInitialVisibility } from "./initializeInitialVisibility"; 6 | import { useScroll } from "./useScroll"; 7 | 8 | // Time interval B 'overlaps' A if: 9 | // B starts after A starts but before A finishes. 10 | // B starts before A starts and finishes after A starts. 11 | function areOverlapping(A, B) { 12 | if (B[0] < A[0]) { 13 | return B[1] > A[0]; 14 | } 15 | return B[0] < A[1]; 16 | } 17 | 18 | export const useVisibilityAndDimension = ({ 19 | root, 20 | axis, 21 | containerHeight, 22 | itemsCount, 23 | itemHeight, 24 | averageItemHeight, 25 | batchSize, 26 | batchBufferDistance 27 | }) => { 28 | const [dimensions, setDimension] = useDimensions( 29 | initializeDimensions({ 30 | itemsCount, 31 | axis, 32 | itemHeight, 33 | averageItemHeight, 34 | batchSize 35 | }) 36 | ); 37 | const [visibility, setVisibility] = useVisibility( 38 | initializeInitialVisibility({ 39 | itemsCount, 40 | axis, 41 | containerHeight, 42 | itemHeight, 43 | averageItemHeight, 44 | batchSize 45 | }) 46 | ); 47 | const scrollOffset = useScroll({ root, axis }); 48 | 49 | useEffect(() => { 50 | const renderWindow = [ 51 | scrollOffset - batchBufferDistance, 52 | scrollOffset + containerHeight + batchBufferDistance 53 | ]; 54 | const totalBatches = Math.ceil(itemsCount / batchSize); 55 | 56 | let nextTotal = 0; 57 | const nextVisibility = []; 58 | for (let i = 0; i < totalBatches; i += 1) { 59 | const currentHeight = nextTotal; 60 | const nextHeight = nextTotal + dimensions[i].height; 61 | nextVisibility[i] = areOverlapping(renderWindow, [ 62 | currentHeight, 63 | nextHeight 64 | ]); 65 | nextTotal = nextHeight; 66 | } 67 | 68 | const visibilityChanged = nextVisibility.some( 69 | (e, i) => e !== visibility[i] 70 | ); 71 | if (visibilityChanged) { 72 | setVisibility(nextVisibility); 73 | } 74 | }, [ 75 | batchSize, 76 | containerHeight, 77 | setVisibility, 78 | dimensions, 79 | itemsCount, 80 | scrollOffset, 81 | visibility, 82 | batchBufferDistance 83 | ]); 84 | 85 | return [dimensions, visibility, setDimension]; 86 | }; 87 | -------------------------------------------------------------------------------- /packages/storybook/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ganapativs/react", 3 | "rules": { 4 | "import/prefer-default-export": 0, 5 | "react/prop-types" : 0 6 | }, 7 | "globals": { 8 | "module": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/storybook/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-viewport/register'; 2 | import '@storybook/addon-options/register'; 3 | import '@storybook/addon-storysource/register'; 4 | import 'storybook-readme/register'; 5 | // import '@storybook/addon-knobs/register'; 6 | // import '@storybook/addon-actions/register'; 7 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { addParameters, configure } from '@storybook/react'; 2 | import { themes } from '@storybook/theming'; 3 | 4 | addParameters({ 5 | options: { 6 | name: 'Delightful Scroller', 7 | showAddonPanel: true, 8 | theme: themes.dark, 9 | } 10 | }); 11 | 12 | function loadStories() { 13 | const req = require.context('../stories', true, /\.stories\.js$/); 14 | req.keys().forEach(filename => req(filename)); 15 | } 16 | 17 | configure(loadStories, module); 18 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({config, mode}) => { 2 | config.module.rules.push({ 3 | test: /\.(story|stories)\.jsx?$/, 4 | loaders: [require.resolve('@storybook/addon-storysource/loader')], 5 | enforce: 'pre', 6 | }); 7 | 8 | const jsRule = config.module.rules.find(rule => { 9 | return String(rule.test) === String(/\.(mjs|jsx?)$/) 10 | }); 11 | 12 | /** Exclude node_modules from sub-directories as well */ 13 | jsRule.exclude = /node_modules/ 14 | 15 | return config; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/storybook/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | X-Frame-Options: SAMEORIGIN 3 | X-XSS-Protection: 1; mode=block 4 | /img/* 5 | Cache-Control: public, max-age=604800, s-max-age=604800 6 | /*.css 7 | Cache-Control: public, max-age=604800, s-max-age=604800 8 | /*.js 9 | Cache-Control: public, max-age=604800, s-max-age=604800 10 | -------------------------------------------------------------------------------- /packages/storybook/babel-plugin-macros.config.js: -------------------------------------------------------------------------------- 1 | // https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/user.md 2 | module.exports = { 3 | styledComponents: { 4 | pure: true 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /packages/storybook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-delightful-scroller-demo", 3 | "version": "0.0.0", 4 | "description": "Demo for 'react-delightful-scroller' package", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "storybook": "start-storybook", 9 | "build-storybook": "build-storybook -c .storybook -o build", 10 | "postbuild-storybook": "cp ./_headers ./build", 11 | "lint": "eslint './stories/**/*' --quiet --fix" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.5.5", 15 | "@ganapativs/eslint-config-react": "^0.0.3", 16 | "babel-loader": "^8.0.6" 17 | }, 18 | "dependencies": { 19 | "@storybook/addon-actions": "^5.1.9", 20 | "@storybook/addon-knobs": "^5.1.9", 21 | "@storybook/addon-options": "^5.1.9", 22 | "@storybook/addon-storysource": "^5.1.9", 23 | "@storybook/addon-viewport": "^5.1.9", 24 | "@storybook/react": "^5.1.9", 25 | "faker": "^4.1.0", 26 | "react": "^16.8.6", 27 | "react-delightful-scroller": "latest", 28 | "storybook-addon-smart-knobs": "^5.0.0", 29 | "storybook-readme": "^5.0.5", 30 | "styled-components": "^4.3.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/storybook/stories/base-components/BaseDefaultStory.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import DelightfulScroller from "react-delightful-scroller"; 3 | import { getItems } from "../utils/helpers"; 4 | import { RenderItem } from "../shared/RenderItem"; 5 | import { Container } from "../shared/Container"; 6 | 7 | export const BaseDefaultStory = props => { 8 | const [items, setItems] = useState(getItems(100)); 9 | const ItemRenderer = p => ( 10 | 11 | ); 12 | 13 | return ( 14 | 15 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/storybook/stories/base-components/BaseDynamicHeightInfinite.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import DelightfulScroller from "react-delightful-scroller"; 3 | import { getItems } from "../utils/helpers"; 4 | import { RenderItem } from "../shared/RenderItem"; 5 | import { RenderContainer } from "../shared/RenderContainer"; 6 | import { RenderLoader } from "../shared/RenderLoader"; 7 | import { Container } from "../shared/Container"; 8 | 9 | export const BaseDynamicHeightInfinite = props => { 10 | const [items, setItems] = useState([]); 11 | const ref = useRef(null); 12 | const loading = useRef(false); 13 | const timer = useRef(false); 14 | const ItemRenderer = p => ( 15 | 16 | ); 17 | 18 | useEffect(() => { 19 | console.log("Container reference: ", ref); 20 | }, []); 21 | 22 | useEffect(() => { 23 | if (loading.current) { 24 | loading.current = false; 25 | } 26 | return () => clearTimeout(timer.current); 27 | }); 28 | 29 | // eslint-disable-next-line no-unused-vars 30 | const onFetchMore = ({ size, itemsCount, batchSize }) => { 31 | if (!loading.current) { 32 | loading.current = true; 33 | timer.current = setTimeout(() => { 34 | const newItems = getItems(100); 35 | setItems([...items, ...newItems]); 36 | }, 1000); 37 | } 38 | }; 39 | 40 | return ( 41 | 42 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /packages/storybook/stories/base-components/BaseDynamicHeightInfiniteAnimated.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import DelightfulScroller from "react-delightful-scroller"; 3 | import { getItems } from "../utils/helpers"; 4 | import { RenderItem } from "../shared/RenderItem"; 5 | import { RenderContainer } from "../shared/RenderContainer"; 6 | import { RenderLoader } from "../shared/RenderLoader"; 7 | import { Container } from "../shared/Container"; 8 | 9 | const threshold = []; 10 | for (let i = 0; i <= 1.0; i += 0.01) { 11 | threshold.push(i); 12 | } 13 | 14 | const WrappedRenderItem = props => { 15 | const ref = useRef(null); 16 | 17 | const callback = ([entry]) => { 18 | // Chrome triggering intersectionRatio greater than 1 🙆‍♂️ 19 | // https://github.com/w3c/IntersectionObserver/issues/147 20 | if (entry.isIntersecting) { 21 | const ratio = Math.min(entry.intersectionRatio, 1); 22 | ref.current.style.opacity = ratio; 23 | ref.current.style.filter = `blur(${10 - 10 * ratio}px) grayscale(${1 - 24 | ratio})`; 25 | ref.current.style.transform = `scaleX(${1 - (0.2 - 0.2 * ratio)})`; 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | const targetNode = ref.current; 31 | const options = { 32 | threshold 33 | }; 34 | const observer = new IntersectionObserver(callback, options); 35 | observer.observe(targetNode); 36 | 37 | return () => { 38 | observer.unobserve(targetNode); 39 | }; 40 | }, []); 41 | 42 | return ( 43 |
44 | 45 |
46 | ); 47 | }; 48 | 49 | export const BaseDynamicHeightInfiniteAnimated = props => { 50 | const [items, setItems] = useState([]); 51 | const ref = useRef(null); 52 | const loading = useRef(false); 53 | const timer = useRef(false); 54 | const ItemRenderer = p => ( 55 | 56 | ); 57 | 58 | useEffect(() => { 59 | console.log("Container reference: ", ref); 60 | }, []); 61 | 62 | useEffect(() => { 63 | if (loading.current) { 64 | loading.current = false; 65 | } 66 | return () => clearTimeout(timer.current); 67 | }); 68 | 69 | // eslint-disable-next-line no-unused-vars 70 | const onFetchMore = ({ size, itemsCount, batchSize }) => { 71 | if (!loading.current) { 72 | loading.current = true; 73 | timer.current = setTimeout(() => { 74 | const newItems = getItems(100); 75 | setItems([...items, ...newItems]); 76 | }, 1000); 77 | } 78 | }; 79 | 80 | return ( 81 | 82 | 94 | 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /packages/storybook/stories/base-components/BaseFixedHeightInfinite.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import DelightfulScroller from "react-delightful-scroller"; 3 | import { getItems } from "../utils/helpers"; 4 | import { RenderItem } from "../shared/RenderItem"; 5 | import { RenderContainer } from "../shared/RenderContainer"; 6 | import { RenderLoader } from "../shared/RenderLoader"; 7 | import { Container } from "../shared/Container"; 8 | 9 | export const BaseFixedHeightInfinite = props => { 10 | const [items, setItems] = useState([]); 11 | const ref = useRef(null); 12 | const loading = useRef(false); 13 | const timer = useRef(false); 14 | const ItemRenderer = p => ( 15 | 16 | ); 17 | 18 | useEffect(() => { 19 | console.log("Container reference: ", ref); 20 | }, []); 21 | 22 | useEffect(() => { 23 | if (loading.current) { 24 | loading.current = false; 25 | } 26 | return () => clearTimeout(timer.current); 27 | }); 28 | 29 | // eslint-disable-next-line no-unused-vars 30 | const onFetchMore = ({ size, itemsCount, batchSize }) => { 31 | if (!loading.current) { 32 | loading.current = true; 33 | timer.current = setTimeout(() => { 34 | const newItems = getItems(100); 35 | setItems([...items, ...newItems]); 36 | }, 1000); 37 | } 38 | }; 39 | 40 | return ( 41 | 42 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /packages/storybook/stories/custom-container/default.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { CustomScrollContainer } from "../shared/CustomScrollContainer"; 5 | import { BaseDefaultStory } from "../base-components/BaseDefaultStory"; 6 | 7 | const CustomContainerScroller = () => { 8 | const scrollRef = useRef(null); 9 | 10 | return ( 11 | 12 | scrollRef.current} /> 13 | 14 | ); 15 | }; 16 | 17 | configureStory(storiesOf("Custom scroll container", module)).add( 18 | "Minimal usage", 19 | () => 20 | ); 21 | -------------------------------------------------------------------------------- /packages/storybook/stories/custom-container/infinite-dynamic-animatable.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseDynamicHeightInfiniteAnimated } from "../base-components/BaseDynamicHeightInfiniteAnimated"; 5 | import { CustomScrollContainer } from "../shared/CustomScrollContainer"; 6 | 7 | const CustomContainerScroller = () => { 8 | const scrollRef = useRef(null); 9 | 10 | return ( 11 | 12 | scrollRef.current} /> 13 | 14 | ); 15 | }; 16 | 17 | configureStory(storiesOf("Custom scroll container", module)).add( 18 | "Infinite scroll - animated dynamic height items", 19 | () => 20 | ); 21 | -------------------------------------------------------------------------------- /packages/storybook/stories/custom-container/infinite-dynamic.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseDynamicHeightInfinite } from "../base-components/BaseDynamicHeightInfinite"; 5 | import { CustomScrollContainer } from "../shared/CustomScrollContainer"; 6 | 7 | const CustomContainerScroller = () => { 8 | const scrollRef = useRef(null); 9 | 10 | return ( 11 | 12 | scrollRef.current} /> 13 | 14 | ); 15 | }; 16 | 17 | configureStory(storiesOf("Custom scroll container", module)).add( 18 | "Infinite scroll - dynamic height items", 19 | () => 20 | ); 21 | -------------------------------------------------------------------------------- /packages/storybook/stories/custom-container/infinite-fixed.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseFixedHeightInfinite } from "../base-components/BaseFixedHeightInfinite"; 5 | import { CustomScrollContainer } from "../shared/CustomScrollContainer"; 6 | 7 | const CustomContainerScroller = () => { 8 | const scrollRef = useRef(null); 9 | 10 | return ( 11 | 12 | scrollRef.current} /> 13 | 14 | ); 15 | }; 16 | 17 | configureStory(storiesOf("Custom scroll container", module)).add( 18 | "Infinite scroll - fixed height items", 19 | () => 20 | ); 21 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/Card.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components/macro"; 2 | 3 | export const Card = styled.div` 4 | margin-bottom: 10px; 5 | white-space: pre-line; 6 | background: var(--darkGrey); 7 | position: relative; 8 | z-index: 0; 9 | overflow: hidden; 10 | line-height: 24px; 11 | border-bottom: 4px solid var(--darkYellow); 12 | `; 13 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/Container.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components/macro"; 3 | import { Warning } from "./Warning"; 4 | 5 | const BaseContainer = styled.div` 6 | max-width: 700px; 7 | margin: 0px auto; 8 | padding: 10px 0; 9 | 10 | @media screen and (max-width: 767px) { 11 | padding: 10px; 12 | } 13 | `; 14 | 15 | export const Container = ({ children }) => { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/CustomScrollContainer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components/macro"; 3 | 4 | const Wrapper = styled.div` 5 | height: 500px; 6 | overflow-x: hidden; 7 | overflow-y: auto; 8 | background: #111; 9 | padding: 10px; 10 | margin: 30px; 11 | `; 12 | 13 | export const CustomScrollContainer = React.forwardRef( 14 | ({ children, style }, ref) => { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | } 21 | ); 22 | 23 | CustomScrollContainer.displayName = "CustomScrollContainer"; 24 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/DetectUnmount.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const DetectUnmount = () => { 4 | useEffect(() => { 5 | return () => console.log("Unmounts"); 6 | }, []); 7 | 8 | return "Sample component to check if the component is unmounting between renders"; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/RenderContainer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Keep components outside render, creating new instance of 4 | // components in each update will discard(unmount) 5 | // old components and re-creates them inside scroller 6 | export const RenderContainer = ({ children, forwardRef }) => { 7 | return
{children}
; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/RenderItem.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components/macro"; 3 | import { Card } from "./Card"; 4 | 5 | const Quote = styled.span` 6 | position: absolute; 7 | font-size: 80px; 8 | line-height: 80px; 9 | opacity: 0.1; 10 | top: -20px; 11 | `; 12 | 13 | const Phrase = styled.p` 14 | margin-left: 42px; 15 | font-size: 18px; 16 | line-height: 26px; 17 | font-family: "Overpass Mono", monospace; 18 | 19 | &::first-letter { 20 | text-transform: uppercase; 21 | } 22 | `; 23 | 24 | const FlexRow = styled.div` 25 | display: flex; 26 | flex-direction: row; 27 | align-items: center; 28 | `; 29 | 30 | const FlexRowWithPadding = styled(FlexRow)` 31 | padding: 10px 0; 32 | `; 33 | 34 | const FlexOne = styled.div` 35 | flex: 1; 36 | `; 37 | const CardWrapper = styled(FlexOne)` 38 | padding: 20px; 39 | box-shadow: -1px 0 4px -3px var(--darkYellow); 40 | `; 41 | 42 | const FlexOneVertical = styled(FlexOne)` 43 | flex: 1; 44 | display: flex; 45 | flex-direction: column; 46 | color: var(--white); 47 | 48 | span:first-child { 49 | font-weight: bold; 50 | font-size: 14px; 51 | } 52 | 53 | span:nth-child(2) { 54 | font-size: 14px; 55 | opacity: 0.6; 56 | } 57 | `; 58 | 59 | const AvatarImg = styled.img` 60 | width: 40px; 61 | height: 40px; 62 | margin-right: 15px; 63 | border: 2px solid var(--darkYellow); 64 | border-radius: 50%; 65 | `; 66 | 67 | const RelativeDiv = styled.div` 68 | position: relative; 69 | margin-top: 30px; 70 | `; 71 | 72 | const FollowButton = styled.button` 73 | color: ${props => (props.following ? "var(--black)" : "var(--white)")}; 74 | border: 2px solid 75 | ${props => (props.following ? "var(--darkYellow)" : "var(--darkYellow)")}; 76 | background: ${props => 77 | props.following ? "var(--darkYellow)" : "transparent"}; 78 | border-radius: 30px; 79 | cursor: pointer; 80 | text-align: center; 81 | padding: 4px 15px; 82 | font-size: 14px; 83 | min-width: 110px; 84 | font-weight: bold; 85 | transition: color 0.1s ease, border-color 0.1s ease, background 0.1s ease; 86 | 87 | .visible-lg:nth-child(2) { 88 | display: none; 89 | } 90 | 91 | .visible-xs { 92 | display: none; 93 | } 94 | 95 | &:hover { 96 | color: ${props => (props.following ? "var(--white)" : "var(--black)")}; 97 | border-color: ${props => 98 | props.following ? "var(--pinky)" : "var(--yellow)"}; 99 | background: ${props => 100 | props.following ? "var(--pinky)" : "var(--yellow)"}; 101 | transition: color 0.15s ease-in, border-color 0.15s ease-in, 102 | background 0.15s ease-in; 103 | 104 | .visible-lg:nth-child(1) { 105 | display: none; 106 | } 107 | .visible-lg:nth-child(2) { 108 | display: inline; 109 | } 110 | } 111 | 112 | @media screen and (max-width: 600px) { 113 | padding: 4px 10px; 114 | min-width: 30px; 115 | 116 | &:hover { 117 | .visible-lg:nth-child(2) { 118 | display: none; 119 | } 120 | } 121 | 122 | .visible-lg { 123 | display: none; 124 | } 125 | .visible-xs { 126 | display: inline !important; 127 | } 128 | } 129 | `; 130 | 131 | const UserArea = ({ item, onFollowToggle }) => ( 132 | 133 | 134 | 135 | {item.name} 136 | {item.company} 137 | 138 | onFollowToggle(!item.following)} 142 | > 143 | {item.following ? ( 144 | <> 145 | Following 146 | Unfollow 147 | x 148 | 149 | ) : ( 150 | <> 151 | Follow 152 | Follow 153 | + 154 | 155 | )} 156 | 157 | 158 | ); 159 | 160 | const Number = styled.div` 161 | writing-mode: vertical-rl; 162 | transform: rotate(180deg); 163 | font-size: 20px; 164 | padding: 0 20px 0 20px; 165 | font-family: "Overpass Mono", monospace; 166 | opacity: 0.2; 167 | `; 168 | 169 | export const RenderItem = ({ 170 | item, 171 | index, 172 | items, 173 | setItems, 174 | showQuotes = true 175 | }) => { 176 | const onFollowToggle = newFollowState => { 177 | const currentItem = items[index]; 178 | const newItems = [...items]; 179 | newItems[index] = { ...currentItem, following: newFollowState }; 180 | setItems(newItems); 181 | }; 182 | 183 | return ( 184 | 185 | 186 | {index.toString().padStart(6, 0)} 187 | 188 | 189 | {showQuotes ? ( 190 | 191 | 192 | 196 | {item.phrase} 197 | 198 | 199 | ) : null} 200 | 201 | 202 | 203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/RenderLoader.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import styled from "styled-components/macro"; 3 | 4 | const Loader = styled.div` 5 | text-align: center; 6 | color: var(--white); 7 | font-weight: bold; 8 | `; 9 | 10 | const Canvas = styled.canvas` 11 | background-color: transparent; 12 | border-radius: 100%; 13 | display: block; 14 | margin: 50px auto 15px auto; 15 | `; 16 | 17 | // https://codepen.io/MishaHahaha/pen/ONQQNY 18 | // eslint-disable-next-line no-unused-vars 19 | export const RenderLoader = ({ 20 | size, 21 | itemsCount, 22 | batchSize, 23 | showPageCount = true 24 | }) => { 25 | const canvasRef = React.useRef(null); 26 | const animation = React.useRef(null); 27 | 28 | useEffect(() => { 29 | const { current: canvas } = canvasRef; 30 | const context = canvas.getContext("2d"); 31 | 32 | const radius = canvas.width / 3; 33 | const angleStep = (Math.PI * 2) / 360; 34 | let theta = 0; 35 | 36 | // change frequencies for getting various curves 37 | const frequencyX = 5; 38 | const frequencyY = 5; 39 | 40 | function draw() { 41 | context.setTransform(1, 0, 0, 1, 0, 0); 42 | context.clearRect(0, 0, canvas.width, canvas.height); 43 | 44 | context.setTransform(1, 0, 0, 1, canvas.width / 2, canvas.height / 2); 45 | context.beginPath(); 46 | 47 | for (let angle = 0; angle < Math.PI * 2; angle += angleStep) { 48 | const x = 49 | Math.sin(angle * frequencyX + theta) * 50 | Math.cos(angle + theta) * 51 | radius; 52 | const y = 53 | Math.cos(angle * frequencyY) * Math.sin(angle + theta) * radius; 54 | if (angle === 0) { 55 | context.moveTo(x, y); 56 | } else { 57 | context.lineTo(x, y); 58 | } 59 | } 60 | 61 | context.lineWidth = 4; 62 | context.strokeStyle = "#ffe877"; 63 | context.stroke(); 64 | context.miterLimit = 0.1; 65 | context.closePath(); 66 | 67 | theta += 0.04; 68 | animation.current = window.requestAnimationFrame(draw); 69 | } 70 | 71 | animation.current = window.requestAnimationFrame(draw); 72 | 73 | return () => window.cancelAnimationFrame(animation.current); 74 | }, []); 75 | 76 | return ( 77 | <> 78 | 79 | {showPageCount ? ( 80 | 81 | {size / batchSize + 1}/{Math.ceil(itemsCount / batchSize)} 82 | 83 | ) : null} 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/Warning.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "styled-components/macro"; 3 | import { getUrlParameter, inIframe, isChrome } from "../utils/helpers"; 4 | 5 | const Banner = styled.div` 6 | background: var(--darkYellow); 7 | padding: 15px; 8 | margin-bottom: 15px; 9 | color: var(--yellow); 10 | border: 4px solid var(--darkYellow); 11 | cursor: pointer; 12 | font-weight: thin; 13 | line-height: 24px; 14 | 15 | &:hover { 16 | opacity: 0.8; 17 | } 18 | `; 19 | 20 | const openStory = () => { 21 | const { location } = window.top; 22 | const { origin } = location; 23 | const path = getUrlParameter("path", location).replace("/story/", ""); 24 | window.open(`${origin}/iframe.html?id=${path}`); 25 | }; 26 | 27 | const isInChromeIframe = isChrome && inIframe(); 28 | 29 | export const Warning = ({ children }) => { 30 | const [hideBanner, setHideBanner] = useState(false); 31 | 32 | return isInChromeIframe && !hideBanner ? ( 33 | <> 34 | 35 | 36 | Scroll performance in iframe is very choppy in Chrome for some reason 37 | 🙆‍♂️ 38 | 39 |
40 | (works well in other browser implementations though 🤔) 41 |
42 |
43 | Click this box to open the story in new tab for better performance 🙏 44 |
45 |

46 | setHideBanner(true)} 49 | > 50 | Show in iframe anyway 51 | 52 |

53 | 54 | ) : ( 55 | children 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/storybook/stories/shared/base.js: -------------------------------------------------------------------------------- 1 | import { withKnobs } from "@storybook/addon-knobs"; 2 | import { withSmartKnobs } from "storybook-addon-smart-knobs"; 3 | import { addReadme } from "storybook-readme"; 4 | import DelightfulReadme from "../../../../README.md"; 5 | 6 | export const configureStory = m => 7 | m 8 | .addDecorator(withSmartKnobs) 9 | .addDecorator(withKnobs) 10 | .addDecorator(addReadme) 11 | .addParameters({ 12 | readme: { 13 | sidebar: DelightfulReadme 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /packages/storybook/stories/utils/helpers.js: -------------------------------------------------------------------------------- 1 | import faker from "faker"; 2 | 3 | export const getItems = (count = 0, editable = true) => 4 | new Array(count).fill(true).map(() => { 5 | return { 6 | phrase: faker.hacker.phrase(), 7 | name: faker.name.findName(), 8 | avatar: faker.image.avatar(), 9 | company: faker.company.companyName(), 10 | following: faker.random.boolean(), 11 | editable 12 | }; 13 | }); 14 | 15 | export const isChrome = 16 | /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); 17 | 18 | export function inIframe() { 19 | try { 20 | return window.self !== window.top; 21 | } catch (e) { 22 | return true; 23 | } 24 | } 25 | 26 | export function getUrlParameter(n, location = window.location) { 27 | const name = n.replace(/[[]/, "\\[").replace(/[\]]/, "\\]"); 28 | const regex = new RegExp(`[\\?&]${name}=([^&#]*)`); 29 | const results = regex.exec(location.search); 30 | return results === null 31 | ? "" 32 | : decodeURIComponent(results[1].replace(/\+/g, " ")); 33 | } 34 | -------------------------------------------------------------------------------- /packages/storybook/stories/window-scroll/default.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseDefaultStory } from "../base-components/BaseDefaultStory"; 5 | 6 | const WindowScroller = BaseDefaultStory; 7 | 8 | configureStory(storiesOf("Window scroller", module)).add( 9 | "Minimal usage", 10 | () => 11 | ); 12 | -------------------------------------------------------------------------------- /packages/storybook/stories/window-scroll/infinite-dynamic-animatable.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseDynamicHeightInfiniteAnimated } from "../base-components/BaseDynamicHeightInfiniteAnimated"; 5 | 6 | const WindowScroller = BaseDynamicHeightInfiniteAnimated; 7 | 8 | configureStory(storiesOf("Window scroller", module)).add( 9 | "Infinite scroll - animated dynamic height items", 10 | () => 11 | ); 12 | -------------------------------------------------------------------------------- /packages/storybook/stories/window-scroll/infinite-dynamic.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseDynamicHeightInfinite } from "../base-components/BaseDynamicHeightInfinite"; 5 | 6 | const WindowScroller = BaseDynamicHeightInfinite; 7 | 8 | configureStory(storiesOf("Window scroller", module)).add( 9 | "Infinite scroll - dynamic height items", 10 | () => 11 | ); 12 | -------------------------------------------------------------------------------- /packages/storybook/stories/window-scroll/infinite-fixed.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { configureStory } from "../shared/base"; 4 | import { BaseFixedHeightInfinite } from "../base-components/BaseFixedHeightInfinite"; 5 | 6 | const WindowScroller = BaseFixedHeightInfinite; 7 | 8 | configureStory(storiesOf("Window scroller", module)).add( 9 | "Infinite scroll - fixed height items", 10 | () => 11 | ); 12 | --------------------------------------------------------------------------------