├── .eslintignore ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.tsx └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 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 | 25 | # webstorm 26 | .idea 27 | 28 | # vs code 29 | .vscode 30 | 31 | # build 32 | /dist 33 | /lib 34 | -------------------------------------------------------------------------------- /.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 | 25 | # webstorm 26 | .idea 27 | 28 | # vs code 29 | .vscode 30 | 31 | # build 32 | /dist 33 | /lib 34 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | lint-staged 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Oleg Grishechkin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React ViewPort List 2 | 3 | [![NPM version](https://img.shields.io/npm/v/react-viewport-list.svg?style=flat)](https://www.npmjs.com/package/react-viewport-list) 4 | ![typescript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-blue.svg) 5 | ![NPM license](https://img.shields.io/npm/l/react-viewport-list.svg?style=flat) 6 | [![NPM total downloads](https://img.shields.io/npm/dt/react-viewport-list.svg?style=flat)](https://npmcharts.com/compare/react-viewport-list?minimal=true) 7 | [![NPM monthly downloads](https://img.shields.io/npm/dm/react-viewport-list.svg?style=flat)](https://npmcharts.com/compare/react-viewport-list?minimal=true) 8 | 9 | > If your application renders long lists of data (hundreds or thousands of rows), we recommended using a technique known as “windowing”. This technique only renders a small subset of your rows at any given time, and can dramatically reduce the time it takes to re-render the components as well as the number of DOM nodes created. 10 | 11 | \- [React.js documentation](https://reactjs.org/docs/optimizing-performance.html#virtualize-long-lists) 12 | 13 | ## 📜 Virtualization for lists with dynamic item size 14 | 15 | ## Features 🔥 16 | 17 | - Simple API like [**Array.Prototype.map()**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) 18 | - Created for **dynamic** item `height` or `width` (if you don't know item size) 19 | - Works perfectly with **Flexbox** (unlike other libraries with `position: absolute`) 20 | - Supports **scroll to index** 21 | - Supports **initial index** 22 | - Supports **vertical** ↕ and **horizontal** ↔ lists️️ 23 | - Tiny (about **2kb** minified+gzipped) 24 | 25 | Try 100k list [demo](https://codesandbox.io/s/react-viewport-list-xw2rt) 26 | 27 | ## Getting Started 28 | 29 | - ### Installation: 30 | 31 | ```shell script 32 | npm install --save react-viewport-list 33 | ``` 34 | 35 | - ### Basic Usage: 36 | 37 | ```typescript jsx 38 | import { useRef } from 'react'; 39 | import { ViewportList } from 'react-viewport-list'; 40 | 41 | const ItemList = ({ 42 | items, 43 | }: { 44 | items: { id: string; title: string }[]; 45 | }) => { 46 | const ref = useRef( 47 | null, 48 | ); 49 | 50 | return ( 51 |
52 | 56 | {(item) => ( 57 |
58 | {item.title} 59 |
60 | )} 61 |
62 |
63 | ); 64 | }; 65 | 66 | export { ItemList }; 67 | ``` 68 | 69 | MutableRefObject\ / RefObject\ / { current: HTMLElement / null } / null 70 | 71 | ## Props 72 | 73 | | name | type | default | description | 74 | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 75 | | `viewportRef` | MutableRefObject\ / RefObject\ / { current: HTMLElement / null } / null | required | Viewport and scroll container.
`document.documentElement` will be used if `viewportRef` not provided. | 76 | | `items` | T[] | [] | Array of items. | 77 | | `itemSize` | number | 0 | Item average (estimated) size (`height` for `axis="y"` and `width` for `axis="x"`) in px.
Size should be greater or equal zero.
Size will be computed automatically if `itemMinSize` not provided or equal zero. | 78 | | `itemMargin` | number | -1 | Item margin (`margin-bottom` for `axis="y"` and `margin-right` for `axis="x"`) in px.
Margin should be greater or equal -1.
Margin will be computed automatically if `margin` not provided or equal -1.
You should still set margin in item styles | 79 | | `overscan` | number | 1 | Count of "overscan" items. | 80 | | `axis` | "y" / "x" | 'y' | Scroll axis:
  • "y" - vertical
  • "x" - horizontal
| 81 | | `initialIndex` | number | -1 | Initial item index in viewport. | 82 | | `initialAlignToTop` | boolean | true | [scrollIntoView](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) param.
Used with `initialIndex` | 83 | | `initialOffset` | number | 0 | Offset after `scrollIntoView` call.
Used with `initialIndex`.
This value will be added to the scroll after scroll to index. | 84 | | `initialDelay` | number | -1 | `setTimeout` delay for initial `scrollToIndex`.
Used with `initialIndex`. | 85 | | `initialPrerender` | number | 0 | Used with `initialIndex`.
This value will modify initial start index and initial end index like `[initialIndex - initialPrerender, initialIndex + initialPrerender]`.
You can use it to avoid blank screen with only one initial item rendered | 86 | | `children` | (item: T, index: number, array: T[]) => ReactNode | required | Item render function.
Similar to [`Array.Prototype.map()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). | 87 | | `onViewportIndexesChange` | (viewportIndexes: [number, number]) => void | optional | Will be called on rendered in viewport indexes change. | 88 | | `overflowAnchor` | "none" / "auto" | "auto" | Compatibility for `overflow-anchor: none`.
Set it to "none" if you use `overflow-anchor: none` in your parent container styles. | 89 | | `withCache` | boolean | true | Cache rendered item heights. | 90 | | `scrollThreshold` | number | 0 | If scroll diff more than `scrollThreshold` setting indexes was skipped. It's can be useful for better fast scroll UX. | 91 | | `renderSpacer` | (props: { ref: MutableRefObject; style: CSSProperties; type: 'top' / 'bottom' }) => ReactNode | ({ ref, style }) => \
| In some rare cases you can use specific elements/styles instead of default spacers | 92 | | `count` | number | optional | You can use items count instead of items directly. Use should use different children: (index: number) => ReactNode | 93 | | `indexesShift ` | number | 0 | Every time you unshift (prepend items) you should increase indexesShift by prepended items count. If you shift items (remove items from top of the list you should decrease indexesShift by removed items count). | 94 | | `getItemBoundingClientRect ` | (element: Element) => DOMRect / { bottom: number; left: number; right: number; top: number; width: number; height: number; } | (element) => element.getBoundingClientRect() | You can use custom rect getter to support `display: contents` or other cases when `element.getBoundingClientRect()` returns "bad" data | 95 | 96 | ## Methods 97 | 98 | ### scrollToIndex 99 | 100 | scrollToIndex method has only one param - options; 101 | 102 | **Options param** 103 | 104 | | name | type | default | description | 105 | | ------------ | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 106 | | `index` | number | -1 | Item index for scroll. | 107 | | `alignToTop` | boolean | true | [scrollIntoView](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) param. Only boolean option supported. | 108 | | `offset` | number | 0 | Offset after `scrollIntoView ` call.
This value will be added to the scroll after scroll to index. | 109 | | `delay` | number | -1 | `setTimeout` delay for initial `scrollToIndex`. | 110 | | `prerender` | number | 0 | This value will modify initial start index and initial end index like `[index - initialPrerender, index + initialPrerender]`.
You can use it to avoid blank screen with only one initial item rendered | 111 | 112 | **Usage** 113 | 114 | ```typescript jsx 115 | import { useRef } from 'react'; 116 | import { ViewportList } from 'react-viewport-list'; 117 | 118 | const ItemList = ({ 119 | items, 120 | }: { 121 | items: { id: string; title: string }[]; 122 | }) => { 123 | const ref = useRef(null); 124 | const listRef = useRef(null); 125 | 126 | return ( 127 |
128 | 133 | {(item) => ( 134 |
135 | {item.title} 136 |
137 | )} 138 |
139 |
148 | ); 149 | }; 150 | 151 | export { ItemList }; 152 | ``` 153 | 154 | ### getScrollPosition 155 | 156 | getScrollPosition returns an object with scroll position: `{ index: number, offset: number }` 157 | 158 | **Returns** 159 | 160 | | name | type | description | 161 | | -------- | ------ | ----------------------------------------------------------------------------------------------------- | 162 | | `index` | number | Item index for scroll. | 163 | | `offset` | number | Offset after `scrollIntoView ` call.
This value will be added to the scroll after scroll to index. | 164 | 165 | If `items=[]` or `count=0` getScrollPosition returns `{ index: -1; offset: 0 }` 166 | 167 | **Usage** 168 | 169 | ```typescript jsx 170 | import { useEffect, useRef } from 'react'; 171 | import { ViewportList } from 'react-viewport-list'; 172 | 173 | const ItemList = ({ 174 | items, 175 | }: { 176 | items: { id: string; title: string }[]; 177 | }) => { 178 | const ref = useRef(null); 179 | const listRef = useRef(null); 180 | 181 | useEffect( 182 | () => () => { 183 | window.sessionStorage.setItem( 184 | 'lastScrollPosition', 185 | JSON.stringify( 186 | listRef.current.getScrollPosition(), 187 | ), 188 | ); 189 | }, 190 | [], 191 | ); 192 | 193 | return ( 194 |
195 | 200 | {(item) => ( 201 |
202 | {item.title} 203 |
204 | )} 205 |
206 |
208 | ); 209 | }; 210 | 211 | export { ItemList }; 212 | ``` 213 | 214 | ## Performance 215 | 216 | If you have performance issues, you can add `will-change: transform` to a scroll container. 217 | 218 | You should remember that in some situations `will-change: transform` can cause performance issues instead of fixing them. 219 | 220 | ```css 221 | .scroll-container { 222 | will-change: transform; 223 | } 224 | ``` 225 | 226 | ## Children pseudo-classes 227 | 228 | `ViewportList` render two elements (spacers) before first rendered item and after last rendered item. 229 | That's why children pseudo-classes like `:nth-child()`, `:last-child`, `:first-child` may work incorrectly. 230 | 231 | ## Margin 232 | 233 | If you want more accurate virtualizing you should use equal margin for all items. 234 | Also, you should use `margin-top` or `margin-bottom` (not both) for `axis="y"` and `margin-right` or `margin-left` (not both) for `axis="x"`. 235 | 236 | If you want to use different margins and stil want more accurate virtualizing you can wrap your items in some element like `
` and use `padding` instead of `margin`. 237 | 238 | ## Non-keyed 239 | 240 | You should avoid non-keyed usage of list. You should provide unique key prop for each list items. 241 | If you have issues with scroll in Safari and other browsers without `overflow-anchor` support, check item's `key` prop. 242 | 243 | ## Advanced Usage 244 | 245 | - ### Grouping 246 | 247 | `ViewportList` render `Fragment` with items in viewport. So, grouping just work. 248 | 249 | ```typescript jsx 250 | import { useRef } from 'react'; 251 | import { ViewportList } from 'react-viewport-list'; 252 | 253 | const GroupedItemList = ({ 254 | keyItems, 255 | items, 256 | }: { 257 | keyItems: { id: string; title: string }[]; 258 | items: { id: string; title: string }[]; 259 | }) => { 260 | const ref = useRef(null); 261 | 262 | return ( 263 |
264 | 265 | Key Items 266 | 267 | 271 | {(item) => ( 272 |
276 | {item.title} 277 |
278 | )} 279 |
280 | Items 281 | 285 | {(item) => ( 286 |
287 | {item.title} 288 |
289 | )} 290 |
291 |
292 | ); 293 | }; 294 | export { GroupedItemList }; 295 | ``` 296 | 297 | - ### Sorting 298 | 299 | You can use [React Sortable HOC](https://github.com/clauderic/react-sortable-hoc) 300 | 301 | ```javascript 302 | import { useRef } from 'react'; 303 | import { 304 | SortableContainer, 305 | SortableElement, 306 | } from 'react-sortable-hoc'; 307 | import { ViewportList } from 'react-viewport-list'; 308 | 309 | const SortableList = SortableContainer( 310 | ({ innerRef, ...rest }) => ( 311 |
312 | ), 313 | ); 314 | 315 | const SortableItem = SortableElement( 316 | (props) =>
, 317 | ); 318 | 319 | const SortableItemList = ({ 320 | items, 321 | onSortEnd, 322 | }) => { 323 | const ref = useRef(null); 324 | 325 | return ( 326 | 331 | 335 | {(item, index) => ( 336 | 341 | {item.title} 342 | 343 | )} 344 | 345 | 346 | ); 347 | }; 348 | 349 | export { SortableItemList }; 350 | ``` 351 | 352 | - ### Scroll to position 353 | 354 | Scroll to position may work incorrectly because scrollHeight and scrollTop (or scrollWidth and scrollLeft) changed automatically while scrolling. 355 | But you can scroll to position with `scrollToIndex` method with `{ index: 0, offset: scrollPosition }`. For initial scroll to position you can use `initialIndex={0}` and `initialOffset={scrollPosition}`. You should remember that after scroll happened scroll position can be not equal to specified offset. 356 | 357 | ```typescript jsx 358 | import { useRef } from 'react'; 359 | import { ViewportList } from 'react-viewport-list'; 360 | 361 | const ItemList = ({ 362 | items, 363 | savedScroll, 364 | }: { 365 | items: { id: string; title: string }[]; 366 | savedScroll: number; 367 | }) => { 368 | const ref = useRef(null); 369 | const listRef = useRef(null); 370 | 371 | return ( 372 |
373 | 380 | {(item) => ( 381 |
382 | {item.title} 383 |
384 | )} 385 |
386 |
397 | ); 398 | }; 399 | 400 | export { ItemList }; 401 | ``` 402 | 403 | - ### Tests 404 | 405 | You can mock ViewportList for unit tests: 406 | 407 | ```javascript 408 | import { 409 | useImperativeHandle, 410 | forwardRef, 411 | } from 'react'; 412 | 413 | export const ViewportListMock = forwardRef( 414 | ({ items = [], children }, ref) => { 415 | useImperativeHandle( 416 | ref, 417 | () => ({ 418 | scrollToIndex: () => {}, 419 | }), 420 | [], 421 | ); 422 | 423 | return ( 424 | <> 425 |
426 | {items.map(children)} 427 |
428 | 429 | ); 430 | }, 431 | ); 432 | 433 | export default ViewportListMock; 434 | ``` 435 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-viewport-list", 3 | "version": "7.1.2", 4 | "description": "📜 Virtualization for lists with dynamic item size", 5 | "keywords": [ 6 | "react", 7 | "viewport", 8 | "list", 9 | "windowing", 10 | "window", 11 | "virualization", 12 | "virtual", 13 | "react-window", 14 | "react-virualized", 15 | "react-tiny-virtual-list", 16 | "react-list", 17 | "react-virtual-list" 18 | ], 19 | "homepage": "https://codesandbox.io/s/react-viewport-list-xw2rt", 20 | "license": "MIT", 21 | "author": { 22 | "name": "Oleg Grishechkin", 23 | "email": "oleggrishechkin@gmail.com", 24 | "url": "https://github.com/oleggrishechkin" 25 | }, 26 | "files": [ 27 | "lib/*" 28 | ], 29 | "main": "lib/index.js", 30 | "types": "lib/index.d.ts", 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/oleggrishechkin/react-viewport-list" 34 | }, 35 | "scripts": { 36 | "lint": "eslint --quiet .", 37 | "lint:fix": "eslint --quiet --fix .", 38 | "prettier:fix": "prettier --write .", 39 | "prettier:fix:readme": "prettier --tab-width 2 --print-width 50 --write README.md", 40 | "prepare": "husky install && rimraf lib && tsc" 41 | }, 42 | "devDependencies": { 43 | "@types/react": "^17.0.2", 44 | "babel-eslint": "^10.1.0", 45 | "configs-og": "^5.0.2", 46 | "eslint": "^8.55.0", 47 | "husky": "^8.0.3", 48 | "lint-staged": "^15.2.0", 49 | "prettier": "^3.1.0", 50 | "react": "^17.0.1", 51 | "rimraf": "^3.0.2", 52 | "typescript": "^5.3.3" 53 | }, 54 | "peerDependencies": { 55 | "react": ">=17.0.0" 56 | }, 57 | "browserslist": { 58 | "production": [ 59 | ">0.2%", 60 | "not dead", 61 | "not op_mini all" 62 | ], 63 | "development": [ 64 | "last 1 chrome version", 65 | "last 1 firefox version", 66 | "last 1 safari version" 67 | ] 68 | }, 69 | "eslintConfig": { 70 | "extends": [ 71 | "./node_modules/configs-og/.eslintrc.js" 72 | ] 73 | }, 74 | "prettier": "configs-og/prettier.config.js", 75 | "lint-staged": { 76 | "*.(js|jsx|ts|tsx)": "eslint --quiet" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useRef, 4 | useEffect, 5 | Fragment, 6 | useImperativeHandle, 7 | useLayoutEffect, 8 | useMemo, 9 | MutableRefObject, 10 | forwardRef, 11 | ForwardedRef, 12 | RefObject, 13 | CSSProperties, 14 | } from 'react'; 15 | 16 | const IS_SSR = typeof window === 'undefined'; 17 | 18 | const IS_TOUCH_DEVICE = 19 | !IS_SSR && 20 | (() => { 21 | try { 22 | return 'ontouchstart' in window || navigator.maxTouchPoints; 23 | } catch { 24 | return false; 25 | } 26 | })(); 27 | 28 | const IS_OVERFLOW_ANCHOR_SUPPORTED = 29 | !IS_SSR && 30 | (() => { 31 | try { 32 | return window.CSS.supports('overflow-anchor: auto'); 33 | } catch { 34 | return false; 35 | } 36 | })(); 37 | 38 | const SHOULD_DELAY_SCROLL = IS_TOUCH_DEVICE && !IS_OVERFLOW_ANCHOR_SUPPORTED; 39 | 40 | const PROP_NAME_FOR_Y_AXIS = { 41 | top: 'top', 42 | bottom: 'bottom', 43 | clientHeight: 'clientHeight', 44 | scrollHeight: 'scrollHeight', 45 | scrollTop: 'scrollTop', 46 | overflowY: 'overflowY', 47 | height: 'height', 48 | minHeight: 'minHeight', 49 | maxHeight: 'maxHeight', 50 | marginTop: 'marginTop', 51 | } as const; 52 | 53 | const PROP_NAME_FOR_X_AXIS = { 54 | top: 'left', 55 | bottom: 'right', 56 | scrollHeight: 'scrollWidth', 57 | clientHeight: 'clientWidth', 58 | scrollTop: 'scrollLeft', 59 | overflowY: 'overflowX', 60 | minHeight: 'minWidth', 61 | height: 'width', 62 | maxHeight: 'maxWidth', 63 | marginTop: 'marginLeft', 64 | } as const; 65 | 66 | const normalizeValue = (min: number, value: number, max = Infinity) => Math.max(Math.min(value, max), min); 67 | 68 | const getDiff = (value1: number, value2: number, step: number) => Math.ceil(Math.abs(value1 - value2) / step); 69 | 70 | const useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect; 71 | 72 | const generateArray = (from: number, to: number, generate: (index: number) => T): T[] => { 73 | const array = []; 74 | 75 | for (let index = from; index < to; index++) { 76 | array.push(generate(index)); 77 | } 78 | 79 | return array; 80 | }; 81 | 82 | const findElement = ({ 83 | fromElement, 84 | toElement, 85 | fromIndex, 86 | asc = true, 87 | compare, 88 | }: { 89 | fromElement: Element; 90 | toElement: Element; 91 | fromIndex: number; 92 | asc?: boolean; 93 | compare: (element: Element, index: number) => boolean; 94 | }) => { 95 | let index = fromIndex; 96 | let element: Element | null = fromElement; 97 | 98 | while (element && element !== toElement) { 99 | if (compare(element, index)) { 100 | return [element, index] as const; 101 | } 102 | 103 | if (asc) { 104 | index++; 105 | element = element.nextSibling as Element | null; 106 | } else { 107 | index--; 108 | element = element.previousSibling as Element | null; 109 | } 110 | } 111 | 112 | return [null, -1] as const; 113 | }; 114 | 115 | const SCROLLABLE_REGEXP = /auto|scroll/gi; 116 | 117 | const findNearestScrollableElement = ( 118 | propName: typeof PROP_NAME_FOR_Y_AXIS | typeof PROP_NAME_FOR_X_AXIS, 119 | node: Element | null, 120 | ): Element | null => { 121 | if (!node || node === document.body || node === document.documentElement) { 122 | return document.documentElement; 123 | } 124 | 125 | const style = window.getComputedStyle(node); 126 | 127 | if (SCROLLABLE_REGEXP.test(style[propName.overflowY]) || SCROLLABLE_REGEXP.test(style.overflow)) { 128 | return node; 129 | } 130 | 131 | return findNearestScrollableElement(propName, node.parentNode as Element | null); 132 | }; 133 | 134 | const getStyle = (propName: typeof PROP_NAME_FOR_Y_AXIS | typeof PROP_NAME_FOR_X_AXIS, size: number, marginTop = 0) => 135 | ({ 136 | padding: 0, 137 | margin: 0, 138 | border: 'none', 139 | visibility: 'hidden', 140 | overflowAnchor: 'none', 141 | [propName.minHeight]: size, 142 | [propName.height]: size, 143 | [propName.maxHeight]: size, 144 | [propName.marginTop]: marginTop, 145 | }) as const; 146 | 147 | export interface ScrollToIndexOptions { 148 | index?: number; 149 | alignToTop?: boolean; 150 | offset?: number; 151 | delay?: number; 152 | prerender?: number; 153 | } 154 | 155 | export interface ViewportListRef { 156 | scrollToIndex: (options: ScrollToIndexOptions) => void; 157 | getScrollPosition: () => { index: number; offset: number }; 158 | } 159 | 160 | export interface ViewportListPropsBase { 161 | viewportRef?: 162 | | MutableRefObject 163 | | RefObject 164 | | { current: HTMLElement | null } 165 | | null; 166 | itemSize?: number; 167 | itemMargin?: number; 168 | overscan?: number; 169 | axis?: 'y' | 'x'; 170 | initialIndex?: ScrollToIndexOptions['index']; 171 | initialAlignToTop?: ScrollToIndexOptions['alignToTop']; 172 | initialOffset?: ScrollToIndexOptions['offset']; 173 | initialDelay?: ScrollToIndexOptions['delay']; 174 | initialPrerender?: ScrollToIndexOptions['prerender']; 175 | onViewportIndexesChange?: (viewportIndexes: [number, number]) => void; 176 | overflowAnchor?: 'none' | 'auto'; 177 | withCache?: boolean; 178 | scrollThreshold?: number; 179 | renderSpacer?: (props: { ref: MutableRefObject; style: CSSProperties; type: 'top' | 'bottom' }) => any; 180 | indexesShift?: number; 181 | getItemBoundingClientRect?: (element: Element) => 182 | | DOMRect 183 | | { 184 | bottom: number; 185 | left: number; 186 | right: number; 187 | top: number; 188 | width: number; 189 | height: number; 190 | }; 191 | } 192 | 193 | export interface ViewportListPropsWithItems extends ViewportListPropsBase { 194 | items?: T[]; 195 | children: (item: T, index: number, array: T[]) => any; 196 | } 197 | 198 | export interface ViewportListPropsWithCount extends ViewportListPropsBase { 199 | count: number; 200 | children: (index: number) => any; 201 | } 202 | 203 | const ViewportListInner = ( 204 | { 205 | items = [], 206 | count, 207 | children, 208 | viewportRef, 209 | itemSize = 0, 210 | itemMargin = -1, 211 | overscan = 1, 212 | axis = 'y', 213 | initialIndex = -1, 214 | initialAlignToTop = true, 215 | initialOffset = 0, 216 | initialDelay = -1, 217 | initialPrerender = 0, 218 | onViewportIndexesChange, 219 | overflowAnchor = 'auto', 220 | withCache = true, 221 | scrollThreshold = 0, 222 | renderSpacer = ({ ref, style }) =>
, 223 | indexesShift = 0, 224 | getItemBoundingClientRect = (element) => element.getBoundingClientRect(), 225 | }: ViewportListPropsBase & { items?: T[]; count?: number; children: (...args: any) => any }, 226 | ref: ForwardedRef, 227 | ) => { 228 | const propName = axis === 'y' ? PROP_NAME_FOR_Y_AXIS : PROP_NAME_FOR_X_AXIS; 229 | const withCount = typeof count === 'number'; 230 | const maxIndex = (withCount ? count : items.length) - 1; 231 | const [[estimatedItemHeight, estimatedItemMargin], setItemDimensions] = useState(() => [ 232 | normalizeValue(0, itemSize), 233 | normalizeValue(-1, itemMargin), 234 | ]); 235 | const itemHeightWithMargin = normalizeValue(0, estimatedItemHeight + estimatedItemMargin); 236 | const overscanSize = normalizeValue(0, Math.ceil(overscan * itemHeightWithMargin)); 237 | const [indexes, setIndexes] = useState([initialIndex - initialPrerender, initialIndex + initialPrerender]); 238 | const anchorElementRef = useRef(null); 239 | const anchorIndexRef = useRef(-1); 240 | const topSpacerRef = useRef(null); 241 | const bottomSpacerRef = useRef(null); 242 | const ignoreOverflowAnchorRef = useRef(false); 243 | const lastIndexesShiftRef = useRef(indexesShift); 244 | const cacheRef = useRef([]); 245 | const scrollToIndexOptionsRef = useRef | null>( 246 | initialIndex >= 0 247 | ? { 248 | index: initialIndex, 249 | alignToTop: initialAlignToTop, 250 | offset: initialOffset, 251 | delay: initialDelay, 252 | prerender: initialPrerender, 253 | } 254 | : null, 255 | ); 256 | const scrollToIndexTimeoutIdRef = useRef(null); 257 | const marginTopRef = useRef(0); 258 | const viewportIndexesRef = useRef<[number, number]>([-1, -1]); 259 | const scrollTopRef = useRef(null); 260 | const [startIndex, endIndex] = useMemo(() => { 261 | indexes[0] = normalizeValue(0, indexes[0], maxIndex); 262 | indexes[1] = normalizeValue(indexes[0], indexes[1], maxIndex); 263 | 264 | const shift = indexesShift - lastIndexesShiftRef.current; 265 | 266 | lastIndexesShiftRef.current = indexesShift; 267 | 268 | const topSpacer = topSpacerRef.current; 269 | 270 | if (topSpacer && shift) { 271 | indexes[0] = normalizeValue(0, indexes[0] + shift, maxIndex); 272 | indexes[1] = normalizeValue(indexes[0], indexes[1] + shift, maxIndex); 273 | anchorElementRef.current = topSpacer.nextSibling as Element; 274 | anchorIndexRef.current = indexes[0]; 275 | ignoreOverflowAnchorRef.current = true; 276 | } 277 | 278 | return indexes; 279 | }, [indexesShift, indexes, maxIndex]); 280 | 281 | const topSpacerStyle = useMemo( 282 | () => 283 | getStyle( 284 | propName, 285 | (withCache ? cacheRef.current : []) 286 | .slice(0, startIndex) 287 | .reduce((sum, next) => sum + (next - estimatedItemHeight), startIndex * itemHeightWithMargin), 288 | marginTopRef.current, 289 | ), 290 | [propName, withCache, startIndex, itemHeightWithMargin, estimatedItemHeight], 291 | ); 292 | const bottomSpacerStyle = useMemo( 293 | () => 294 | getStyle( 295 | propName, 296 | (withCache ? cacheRef.current : []) 297 | .slice(endIndex + 1, maxIndex + 1) 298 | .reduce( 299 | (sum, next) => sum + (next - estimatedItemHeight), 300 | itemHeightWithMargin * (maxIndex - endIndex), 301 | ), 302 | ), 303 | [propName, withCache, endIndex, maxIndex, itemHeightWithMargin, estimatedItemHeight], 304 | ); 305 | const getViewport = useMemo(() => { 306 | let autoViewport: any = null; 307 | 308 | return () => { 309 | if (viewportRef) { 310 | if (viewportRef.current === document.body) { 311 | return document.documentElement; 312 | } 313 | 314 | return viewportRef.current; 315 | } 316 | 317 | if (autoViewport && autoViewport.isConnected) { 318 | return autoViewport; 319 | } 320 | 321 | const topSpacer = topSpacerRef.current; 322 | 323 | if (!topSpacer) { 324 | return null; 325 | } 326 | 327 | autoViewport = findNearestScrollableElement(propName, topSpacer.parentNode); 328 | 329 | return autoViewport; 330 | }; 331 | }, [propName, viewportRef]); 332 | const mainFrameRef = useRef(() => {}); 333 | const getScrollPositionRef = useRef(() => ({ index: -1, offset: 0 })); 334 | 335 | useIsomorphicLayoutEffect(() => { 336 | mainFrameRef.current = () => { 337 | const viewport = getViewport(); 338 | const topSpacer = topSpacerRef.current; 339 | const bottomSpacer = bottomSpacerRef.current; 340 | 341 | if (!viewport || !topSpacer || !bottomSpacer) { 342 | return; 343 | } 344 | 345 | const topElement = topSpacer.nextSibling as Element; 346 | const bottomElement = bottomSpacer.previousSibling as Element; 347 | const viewportRect = viewport.getBoundingClientRect(); 348 | const topSpacerRect = topSpacer.getBoundingClientRect(); 349 | const bottomSpacerRect = bottomSpacer.getBoundingClientRect(); 350 | const limits = { 351 | [propName.top]: viewport === document.documentElement ? 0 : viewportRect[propName.top], 352 | [propName.bottom]: 353 | viewport === document.documentElement 354 | ? document.documentElement[propName.clientHeight] 355 | : viewportRect[propName.bottom], 356 | }; 357 | const limitsWithOverscanSize = { 358 | [propName.top]: limits[propName.top] - overscanSize, 359 | [propName.bottom]: limits[propName.bottom] + overscanSize, 360 | }; 361 | 362 | if ( 363 | (marginTopRef.current < 0 && 364 | topSpacerRect[propName.top] - marginTopRef.current >= limitsWithOverscanSize[propName.top]) || 365 | (marginTopRef.current > 0 && topSpacerRect[propName.top] >= limitsWithOverscanSize[propName.top]) || 366 | (marginTopRef.current && scrollToIndexOptionsRef.current) 367 | ) { 368 | topSpacer.style[propName.marginTop] = '0px'; 369 | viewport.style[propName.overflowY] = 'hidden'; 370 | viewport[propName.scrollTop] += -marginTopRef.current; 371 | viewport.style[propName.overflowY] = ''; 372 | marginTopRef.current = 0; 373 | 374 | return; 375 | } 376 | 377 | if (estimatedItemHeight === 0 || estimatedItemMargin === -1) { 378 | let itemsHeightSum = 0; 379 | 380 | findElement({ 381 | fromElement: topElement, 382 | toElement: bottomSpacer, 383 | fromIndex: startIndex, 384 | compare: (element) => { 385 | itemsHeightSum += getItemBoundingClientRect(element)[propName.height]; 386 | 387 | return false; 388 | }, 389 | }); 390 | 391 | if (!itemsHeightSum) { 392 | return; 393 | } 394 | 395 | const renderedItemsCount = endIndex - startIndex + 1; 396 | const nextItemHeight = 397 | estimatedItemHeight === 0 ? Math.ceil(itemsHeightSum / renderedItemsCount) : estimatedItemHeight; 398 | const nextItemMargin = 399 | estimatedItemMargin === -1 400 | ? Math.ceil( 401 | (bottomSpacerRect[propName.top] - topSpacerRect[propName.bottom] - itemsHeightSum) / 402 | renderedItemsCount, 403 | ) 404 | : estimatedItemMargin; 405 | 406 | setItemDimensions([nextItemHeight, nextItemMargin]); 407 | 408 | return; 409 | } 410 | 411 | if (scrollToIndexTimeoutIdRef.current) { 412 | return; 413 | } 414 | 415 | if (scrollToIndexOptionsRef.current) { 416 | const targetIndex = normalizeValue(0, scrollToIndexOptionsRef.current.index, maxIndex); 417 | 418 | if (targetIndex < startIndex || targetIndex > endIndex) { 419 | setIndexes([ 420 | targetIndex - scrollToIndexOptionsRef.current.prerender, 421 | targetIndex + scrollToIndexOptionsRef.current.prerender, 422 | ]); 423 | 424 | return; 425 | } 426 | 427 | const [targetElement] = findElement({ 428 | fromElement: topElement, 429 | toElement: bottomSpacer, 430 | fromIndex: startIndex, 431 | compare: (_, index) => index === targetIndex, 432 | }); 433 | 434 | if (!targetElement) { 435 | return; 436 | } 437 | 438 | const { alignToTop, offset, delay } = scrollToIndexOptionsRef.current; 439 | 440 | scrollToIndexOptionsRef.current = null; 441 | 442 | const scrollToElement = () => { 443 | const elementRect = getItemBoundingClientRect(targetElement); 444 | const shift = alignToTop 445 | ? elementRect[propName.top] - limits[propName.top] + offset 446 | : elementRect[propName.bottom] - 447 | limits[propName.top] - 448 | viewport[propName.clientHeight] + 449 | offset; 450 | 451 | viewport[propName.scrollTop] += shift; 452 | scrollToIndexTimeoutIdRef.current = null; 453 | }; 454 | const scrollToElementDelay = delay < 0 && SHOULD_DELAY_SCROLL ? 30 : delay; 455 | 456 | if (scrollToElementDelay > 0) { 457 | scrollToIndexTimeoutIdRef.current = setTimeout(scrollToElement, scrollToElementDelay); 458 | 459 | return; 460 | } 461 | 462 | scrollToElement(); 463 | 464 | return; 465 | } 466 | 467 | if (scrollTopRef.current === null) { 468 | scrollTopRef.current = viewport.scrollTop; 469 | } else if (scrollTopRef.current !== viewport.scrollTop) { 470 | const diff = Math.abs(viewport.scrollTop - scrollTopRef.current); 471 | 472 | scrollTopRef.current = viewport.scrollTop; 473 | 474 | if (scrollThreshold > 0 && diff > scrollThreshold) { 475 | return; 476 | } 477 | } 478 | 479 | const topSecondElement = topElement === bottomSpacer ? bottomSpacer : (topElement.nextSibling as Element); 480 | const bottomSecondElement = 481 | bottomElement === topSpacer ? topSpacer : (bottomElement.previousSibling as Element); 482 | const averageSize = Math.ceil( 483 | (bottomSpacerRect[propName.top] - topSpacerRect[propName.bottom]) / (endIndex + 1 - startIndex), 484 | ); 485 | const isAllAboveTop = topSpacerRect[propName.bottom] > limitsWithOverscanSize[propName.bottom]; 486 | const isAllBelowBottom = bottomSpacerRect[propName.top] < limitsWithOverscanSize[propName.top]; 487 | const isTopBelowTop = 488 | !isAllAboveTop && 489 | !isAllBelowBottom && 490 | topSpacerRect[propName.bottom] > limitsWithOverscanSize[propName.top]; 491 | const isBottomAboveBottom = 492 | !isAllAboveTop && 493 | !isAllBelowBottom && 494 | bottomSpacerRect[propName.top] < limitsWithOverscanSize[propName.bottom]; 495 | const isBottomSecondAboveTop = 496 | !isAllAboveTop && 497 | !isAllBelowBottom && 498 | (bottomSecondElement === topSpacer ? topSpacerRect : getItemBoundingClientRect(bottomSecondElement))[ 499 | propName.bottom 500 | ] > limitsWithOverscanSize[propName.bottom]; 501 | const isTopSecondAboveTop = 502 | !isAllAboveTop && 503 | !isAllBelowBottom && 504 | (topSecondElement === bottomSpacer ? bottomSpacerRect : getItemBoundingClientRect(topSecondElement))[ 505 | propName.top 506 | ] < limitsWithOverscanSize[propName.top]; 507 | let nextStartIndex = startIndex; 508 | let nextEndIndex = endIndex; 509 | 510 | if (isAllAboveTop) { 511 | nextStartIndex -= getDiff( 512 | topSpacerRect[propName.bottom], 513 | limitsWithOverscanSize[propName.top], 514 | averageSize, 515 | ); 516 | nextEndIndex -= getDiff( 517 | bottomSpacerRect[propName.top], 518 | limitsWithOverscanSize[propName.bottom], 519 | averageSize, 520 | ); 521 | } 522 | 523 | if (isAllBelowBottom) { 524 | nextEndIndex += getDiff( 525 | bottomSpacerRect[propName.top], 526 | limitsWithOverscanSize[propName.bottom], 527 | averageSize, 528 | ); 529 | nextStartIndex += getDiff( 530 | topSpacerRect[propName.bottom], 531 | limitsWithOverscanSize[propName.top], 532 | averageSize, 533 | ); 534 | } 535 | 536 | if (isTopBelowTop) { 537 | nextStartIndex -= getDiff( 538 | topSpacerRect[propName.bottom], 539 | limitsWithOverscanSize[propName.top], 540 | averageSize, 541 | ); 542 | } 543 | 544 | if (isBottomAboveBottom) { 545 | nextEndIndex += getDiff( 546 | bottomSpacerRect[propName.top], 547 | limitsWithOverscanSize[propName.bottom], 548 | averageSize, 549 | ); 550 | } 551 | 552 | if (isBottomSecondAboveTop) { 553 | const [, index] = findElement({ 554 | fromElement: bottomElement, 555 | toElement: topSpacer, 556 | fromIndex: endIndex, 557 | asc: false, 558 | compare: (element) => 559 | getItemBoundingClientRect(element)[propName.bottom] <= limitsWithOverscanSize[propName.bottom], 560 | }); 561 | 562 | if (index !== -1) { 563 | nextEndIndex = index + 1; 564 | } 565 | } 566 | 567 | if (isTopSecondAboveTop) { 568 | const [, index] = findElement({ 569 | fromElement: topElement, 570 | toElement: bottomSpacer, 571 | fromIndex: startIndex, 572 | compare: (element) => 573 | getItemBoundingClientRect(element)[propName.top] >= limitsWithOverscanSize[propName.top], 574 | }); 575 | 576 | if (index !== -1) { 577 | nextStartIndex = index - 1; 578 | } 579 | } 580 | 581 | if (onViewportIndexesChange) { 582 | let [, startViewportIndex] = findElement({ 583 | fromElement: topElement, 584 | toElement: bottomSpacer, 585 | fromIndex: startIndex, 586 | compare: (element) => getItemBoundingClientRect(element)[propName.bottom] > limits[propName.top], 587 | }); 588 | 589 | if (startViewportIndex === -1) { 590 | startViewportIndex = startIndex; 591 | } 592 | 593 | let [, endViewportIndex] = findElement({ 594 | fromElement: bottomElement, 595 | toElement: topSpacer, 596 | fromIndex: endIndex, 597 | asc: false, 598 | compare: (element) => getItemBoundingClientRect(element)[propName.top] < limits[propName.bottom], 599 | }); 600 | 601 | if (endViewportIndex === -1) { 602 | endViewportIndex = endIndex; 603 | } 604 | 605 | if ( 606 | startViewportIndex !== viewportIndexesRef.current[0] || 607 | endViewportIndex !== viewportIndexesRef.current[1] 608 | ) { 609 | viewportIndexesRef.current = [startViewportIndex, endViewportIndex]; 610 | onViewportIndexesChange(viewportIndexesRef.current); 611 | } 612 | } 613 | 614 | nextStartIndex = normalizeValue(0, nextStartIndex, maxIndex); 615 | nextEndIndex = normalizeValue(nextStartIndex, nextEndIndex, maxIndex); 616 | 617 | if (nextStartIndex === startIndex && nextEndIndex === endIndex) { 618 | return; 619 | } 620 | 621 | if (nextStartIndex !== startIndex) { 622 | if (startIndex >= nextStartIndex) { 623 | anchorElementRef.current = topElement; 624 | anchorIndexRef.current = startIndex; 625 | } else { 626 | const [anchorElement, anchorElementIndex] = findElement({ 627 | fromElement: topElement, 628 | toElement: bottomSpacer, 629 | fromIndex: startIndex, 630 | compare: (element, index) => { 631 | if (index === nextStartIndex) { 632 | return true; 633 | } 634 | 635 | const elementRect = getItemBoundingClientRect(element); 636 | 637 | if (elementRect[propName.height] !== estimatedItemHeight) { 638 | cacheRef.current[index] = elementRect[propName.height]; 639 | } 640 | 641 | return false; 642 | }, 643 | }); 644 | 645 | if (anchorElement) { 646 | anchorElementRef.current = anchorElement; 647 | anchorIndexRef.current = anchorElementIndex; 648 | } else { 649 | anchorElementRef.current = bottomElement; 650 | anchorIndexRef.current = endIndex; 651 | } 652 | } 653 | } 654 | 655 | setIndexes([nextStartIndex, nextEndIndex]); 656 | }; 657 | getScrollPositionRef.current = () => { 658 | const viewport = getViewport(); 659 | const topSpacer = topSpacerRef.current; 660 | const bottomSpacer = bottomSpacerRef.current; 661 | 662 | let scrollIndex = -1; 663 | let scrollOffset = 0; 664 | 665 | if (!viewport || !topSpacer || !bottomSpacer) { 666 | return { index: scrollIndex, offset: scrollOffset }; 667 | } 668 | 669 | const topElement = topSpacer.nextSibling as Element; 670 | const viewportRect = viewport.getBoundingClientRect(); 671 | const limits = { 672 | [propName.top]: viewport === document.documentElement ? 0 : viewportRect[propName.top], 673 | [propName.bottom]: 674 | viewport === document.documentElement 675 | ? document.documentElement[propName.clientHeight] 676 | : viewportRect[propName.bottom], 677 | }; 678 | 679 | findElement({ 680 | fromElement: topElement, 681 | toElement: bottomSpacer, 682 | fromIndex: startIndex, 683 | compare: (element, index) => { 684 | const rect = getItemBoundingClientRect(element); 685 | 686 | scrollIndex = index; 687 | scrollOffset = limits[propName.top] - rect[propName.top]; 688 | 689 | return rect[propName.bottom] > limits[propName.top]; 690 | }, 691 | }); 692 | 693 | return { index: scrollIndex, offset: scrollOffset }; 694 | }; 695 | }); 696 | 697 | let anchorHeightOnRender: number | undefined; 698 | 699 | if (anchorElementRef.current && getViewport() && topSpacerRef.current) { 700 | anchorHeightOnRender = 701 | getItemBoundingClientRect(anchorElementRef.current)[propName.top] - 702 | (getViewport() === document.documentElement ? 0 : getViewport().getBoundingClientRect()[propName.top]); 703 | } 704 | 705 | useIsomorphicLayoutEffect(() => { 706 | anchorElementRef.current = null; 707 | 708 | const anchorIndex = anchorIndexRef.current; 709 | const ignoreOverflowAnchor = ignoreOverflowAnchorRef.current; 710 | 711 | anchorIndexRef.current = -1; 712 | ignoreOverflowAnchorRef.current = false; 713 | 714 | const viewport = getViewport(); 715 | const topSpacer = topSpacerRef.current; 716 | const bottomSpacer = bottomSpacerRef.current; 717 | 718 | if ( 719 | anchorIndex === -1 || 720 | !viewport || 721 | !topSpacer || 722 | !bottomSpacer || 723 | anchorHeightOnRender === undefined || 724 | (IS_OVERFLOW_ANCHOR_SUPPORTED && overflowAnchor !== 'none' && !ignoreOverflowAnchor) 725 | ) { 726 | return; 727 | } 728 | 729 | let top = null; 730 | 731 | if (anchorIndex >= startIndex && anchorIndex <= endIndex) { 732 | const [anchorElement] = findElement({ 733 | fromElement: topSpacer.nextSibling as Element, 734 | toElement: bottomSpacer, 735 | fromIndex: startIndex, 736 | compare: (_, index) => index === anchorIndex, 737 | }); 738 | 739 | if (anchorElement) { 740 | top = getItemBoundingClientRect(anchorElement)[propName.top]; 741 | } 742 | } else { 743 | if (anchorIndex < startIndex) { 744 | top = 745 | topSpacer.getBoundingClientRect()[propName.top] + 746 | (withCache ? cacheRef.current : []) 747 | .slice(0, anchorIndex) 748 | .reduce((sum, next) => sum + (next - estimatedItemHeight), anchorIndex * itemHeightWithMargin); 749 | } else if (anchorIndex <= maxIndex) { 750 | top = 751 | bottomSpacer.getBoundingClientRect()[propName.top] + 752 | (withCache ? cacheRef.current : []) 753 | .slice(endIndex + 1, anchorIndex) 754 | .reduce( 755 | (sum, next) => sum + (next - estimatedItemHeight), 756 | itemHeightWithMargin * (anchorIndex - 1 - endIndex), 757 | ); 758 | } 759 | } 760 | 761 | if (top === null) { 762 | return; 763 | } 764 | 765 | const offset = 766 | top - 767 | (viewport === document.documentElement ? 0 : viewport.getBoundingClientRect()[propName.top]) - 768 | anchorHeightOnRender; 769 | 770 | if (!offset) { 771 | return; 772 | } 773 | 774 | if (IS_TOUCH_DEVICE) { 775 | marginTopRef.current -= offset; 776 | topSpacer.style[propName.marginTop] = `${marginTopRef.current}px`; 777 | 778 | return; 779 | } 780 | 781 | viewport[propName.scrollTop] += offset; 782 | }, [startIndex]); 783 | useIsomorphicLayoutEffect(() => { 784 | let frameId: number; 785 | const frame = () => { 786 | frameId = requestAnimationFrame(frame); 787 | mainFrameRef.current(); 788 | }; 789 | 790 | frame(); 791 | 792 | return () => { 793 | cancelAnimationFrame(frameId); 794 | 795 | if (scrollToIndexTimeoutIdRef.current) { 796 | clearTimeout(scrollToIndexTimeoutIdRef.current); 797 | } 798 | }; 799 | }, []); 800 | useImperativeHandle( 801 | ref, 802 | () => ({ 803 | scrollToIndex: ({ index = -1, alignToTop = true, offset = 0, delay = -1, prerender = 0 }) => { 804 | scrollToIndexOptionsRef.current = { index, alignToTop, offset, delay, prerender }; 805 | mainFrameRef.current(); 806 | }, 807 | getScrollPosition: () => getScrollPositionRef.current(), 808 | }), 809 | [], 810 | ); 811 | 812 | return ( 813 | 814 | {renderSpacer({ ref: topSpacerRef, style: topSpacerStyle, type: 'top' })} 815 | {(!!count || !!items.length) && 816 | generateArray( 817 | startIndex, 818 | endIndex + 1, 819 | withCount ? children : (index) => children(items[index], index, items), 820 | )} 821 | {renderSpacer({ ref: bottomSpacerRef, style: bottomSpacerStyle, type: 'bottom' })} 822 | 823 | ); 824 | }; 825 | 826 | export interface ViewportList { 827 | ( 828 | props: ViewportListPropsWithItems & { ref?: ForwardedRef }, 829 | ): ReturnType; 830 | (props: ViewportListPropsWithCount & { ref?: ForwardedRef }): ReturnType; 831 | } 832 | 833 | export const ViewportList = forwardRef(ViewportListInner) as ViewportList; 834 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | "allowJs": true /* Allow javascript files to be compiled. */, 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */, 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./lib" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | --------------------------------------------------------------------------------