├── .eslintrc.js ├── .fatherrc.js ├── .github └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── .umirc.ts ├── LICENSE ├── README.md ├── bunfig.toml ├── docs ├── demo │ ├── animate.md │ ├── basic.md │ ├── height.md │ ├── horizontal-scroll.md │ ├── nest.md │ ├── no-virtual.md │ └── switch.md └── index.md ├── examples ├── animate.less ├── animate.tsx ├── basic.less ├── basic.tsx ├── height.tsx ├── horizontal-scroll.tsx ├── nest.tsx ├── no-virtual.tsx └── switch.tsx ├── now.json ├── package.json ├── src ├── Context.tsx ├── Filler.tsx ├── Item.tsx ├── List.tsx ├── ScrollBar.tsx ├── hooks │ ├── useChildren.tsx │ ├── useDiffItem.ts │ ├── useFrameWheel.ts │ ├── useGetSize.ts │ ├── useHeights.tsx │ ├── useMobileTouchMove.ts │ ├── useOriginScroll.ts │ ├── useScrollDrag.ts │ └── useScrollTo.tsx ├── index.ts ├── interface.ts ├── mock.tsx └── utils │ ├── CacheMap.ts │ ├── algorithmUtil.ts │ ├── isFirefox.ts │ └── scrollbarUtil.ts ├── tests ├── __mocks__ │ └── rc-util │ │ └── lib │ │ └── raf.ts ├── list.test.js ├── mock.test.js ├── props.test.js ├── scroll-Firefox.test.js ├── scroll.test.js ├── scrollWidth.test.tsx ├── touch.test.js ├── util.test.js └── utils │ └── domHook.js ├── tsconfig.json └── update-demo.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const base = require('@umijs/fabric/dist/eslint'); 2 | 3 | module.exports = { 4 | ...base, 5 | rules: { 6 | ...base.rules, 7 | 'arrow-parens': 0, 8 | '@typescript-eslint/no-explicit-any': 0, 9 | 'react/no-did-update-set-state': 0, 10 | 'react/no-find-dom-node': 0, 11 | 'no-dupe-class-members': 0, 12 | 'react/sort-comp': 0, 13 | 'no-confusing-arrow': 0, 14 | 'no-unused-expressions': 0, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "48 4 * * 3" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | .doc 3 | node_modules 4 | coverage/ 5 | es/ 6 | lib/ 7 | ~* 8 | yarn.lock 9 | package-lock.json 10 | !tests/__mocks__/rc-util/lib 11 | bun.lockb 12 | 13 | # umi 14 | .umi 15 | .umi-production 16 | .umi-test 17 | .env.local 18 | 19 | .dumi -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | export default defineConfig({ 4 | themeConfig: { 5 | name: 'Tree', 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ 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 16 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-virtual-list 2 | 3 | React Virtual List Component which worked with animation. 4 | 5 | [![NPM version][npm-image]][npm-url] [![dumi](https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square)](https://github.com/umijs/dumi) [![build status][github-actions-image]][github-actions-url] [![Test coverage][coveralls-image]][coveralls-url] [![node version][node-image]][node-url] [![npm download][download-image]][download-url] 6 | 7 | [npm-image]: http://img.shields.io/npm/v/rc-virtual-list.svg?style=flat-square 8 | [npm-url]: http://npmjs.org/package/rc-virtual-list 9 | [github-actions-image]: https://github.com/react-component/virtual-list/workflows/CI/badge.svg 10 | [github-actions-url]: https://github.com/react-component/virtual-list/actions 11 | [coveralls-image]: https://img.shields.io/codecov/c/github/react-component/virtual-list/master.svg?style=flat-square 12 | [coveralls-url]: https://codecov.io/gh/react-component/virtual-list 13 | [node-image]: https://img.shields.io/badge/node.js-%3E=_6.0-green.svg?style=flat-square 14 | [node-url]: http://nodejs.org/download/ 15 | [download-image]: https://img.shields.io/npm/dm/rc-virtual-list.svg?style=flat-square 16 | [download-url]: https://npmjs.org/package/rc-virtual-list 17 | 18 | ## Online Preview 19 | 20 | https://virtual-list-react-component.vercel.app/ 21 | 22 | ## Development 23 | 24 | ```bash 25 | npm install 26 | npm start 27 | open http://localhost:8000/ 28 | ``` 29 | 30 | ## Feature 31 | 32 | - Support react.js 33 | - Support animation 34 | - Support IE11+ 35 | 36 | ## Install 37 | 38 | [![rc-virtual-list](https://nodei.co/npm/rc-virtual-list.png)](https://npmjs.org/package/rc-virtual-list) 39 | 40 | ## Usage 41 | 42 | ```js 43 | import List from 'rc-virtual-list'; 44 | 45 | 46 | {index =>
{index}
} 47 |
; 48 | ``` 49 | 50 | # API 51 | 52 | ## List 53 | 54 | | Prop | Description | Type | Default | 55 | | ---------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | 56 | | children | Render props of item | (item, index, props) => ReactElement | - | 57 | | component | Customize List dom element | string \| Component | div | 58 | | data | Data list | Array | - | 59 | | disabled | Disable scroll check. Usually used on animation control | boolean | false | 60 | | height | List height | number | - | 61 | | itemHeight | Item minimum height | number | - | 62 | | itemKey | Match key with item | string | - | 63 | | styles | style | { horizontalScrollBar?: React.CSSProperties; horizontalScrollBarThumb?: React.CSSProperties; verticalScrollBar?: React.CSSProperties; verticalScrollBarThumb?: React.CSSProperties; } | - | 64 | 65 | `children` provides additional `props` argument to support IE 11 scroll shaking. 66 | It will set `style` to `visibility: hidden` when measuring. You can ignore this if no requirement on IE. 67 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /docs/demo/animate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Animate 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/height.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Height 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/horizontal-scroll.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Horizontal Scroll 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/nest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Nest 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/no-virtual.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: No Virtual 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/switch.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Switch 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: rc-virtual-list 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/animate.less: -------------------------------------------------------------------------------- 1 | .motion { 2 | transition: all 0.3s; 3 | } 4 | 5 | .item { 6 | display: inline-block; 7 | box-sizing: border-box; 8 | margin: 0; 9 | padding: 0 16px; 10 | overflow: hidden; 11 | line-height: 31px; 12 | position: relative; 13 | 14 | &:hover { 15 | background: rgba(255, 0, 0, 0.1); 16 | } 17 | 18 | &::after { 19 | content: ''; 20 | border-bottom: 1px solid gray; 21 | position: absolute; 22 | bottom: 0; 23 | left: 0; 24 | right: 0; 25 | } 26 | 27 | button { 28 | vertical-align: text-top; 29 | margin-right: 8px; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/animate.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | 3 | import * as React from 'react'; 4 | // @ts-ignore 5 | import CSSMotion from 'rc-animate/lib/CSSMotion'; 6 | import classNames from 'classnames'; 7 | import List, { ListRef } from '../src/List'; 8 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; 9 | import './animate.less'; 10 | 11 | let uuid = 0; 12 | function genItem() { 13 | const item = { 14 | id: `key_${uuid}`, 15 | uuid, 16 | }; 17 | uuid += 1; 18 | return item; 19 | } 20 | 21 | const originData: Item[] = []; 22 | for (let i = 0; i < 1000; i += 1) { 23 | originData.push(genItem()); 24 | } 25 | 26 | interface Item { 27 | id: string; 28 | uuid: number; 29 | } 30 | 31 | interface MyItemProps extends Item { 32 | visible: boolean; 33 | motionAppear: boolean; 34 | onClose: (id: string) => void; 35 | onLeave: (id: string) => void; 36 | onAppear: (...args: any[]) => void; 37 | onInsertBefore: (id: string) => void; 38 | onInsertAfter: (id: string) => void; 39 | } 40 | 41 | const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight }); 42 | const getMaxHeight = (node: HTMLElement) => { 43 | return { height: node.scrollHeight }; 44 | }; 45 | const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); 46 | 47 | const MyItem: React.ForwardRefRenderFunction = ( 48 | { 49 | id, 50 | uuid: itemUuid, 51 | visible, 52 | onClose, 53 | onLeave, 54 | onAppear, 55 | onInsertBefore, 56 | onInsertAfter, 57 | motionAppear, 58 | }, 59 | ref, 60 | ) => { 61 | const motionRef = React.useRef(false); 62 | useLayoutEffect(() => { 63 | return () => { 64 | if (motionRef.current) { 65 | onAppear(); 66 | } 67 | }; 68 | }, []); 69 | 70 | return ( 71 | { 78 | motionRef.current = true; 79 | return getMaxHeight(node); 80 | }} 81 | onAppearEnd={onAppear} 82 | onLeaveStart={getCurrentHeight} 83 | onLeaveActive={getCollapsedHeight} 84 | onLeaveEnd={() => { 85 | onLeave(id); 86 | }} 87 | > 88 | {({ className, style }, passedMotionRef) => { 89 | return ( 90 |
96 |
97 | 105 | 113 | 121 | {id} 122 |
123 |
124 | ); 125 | }} 126 |
127 | ); 128 | }; 129 | 130 | const ForwardMyItem = React.forwardRef(MyItem); 131 | 132 | const Demo = () => { 133 | const [data, setData] = React.useState(originData); 134 | const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({}); 135 | const [animating, setAnimating] = React.useState(false); 136 | const [insertIndex, setInsertIndex] = React.useState(); 137 | 138 | const listRef = React.useRef(); 139 | 140 | const onClose = (id: string) => { 141 | setCloseMap({ 142 | ...closeMap, 143 | [id]: true, 144 | }); 145 | }; 146 | 147 | const onLeave = (id: string) => { 148 | const newData = data.filter(item => item.id !== id); 149 | setData(newData); 150 | }; 151 | 152 | const onAppear = (...args: any[]) => { 153 | console.log('Appear:', args); 154 | setAnimating(false); 155 | }; 156 | 157 | function lockForAnimation() { 158 | setAnimating(true); 159 | } 160 | 161 | const onInsertBefore = (id: string) => { 162 | const index = data.findIndex(item => item.id === id); 163 | const newData = [...data.slice(0, index), genItem(), ...data.slice(index)]; 164 | setInsertIndex(index); 165 | setData(newData); 166 | lockForAnimation(); 167 | }; 168 | const onInsertAfter = (id: string) => { 169 | const index = data.findIndex(item => item.id === id) + 1; 170 | const newData = [...data.slice(0, index), genItem(), ...data.slice(index)]; 171 | setInsertIndex(index); 172 | setData(newData); 173 | lockForAnimation(); 174 | }; 175 | 176 | return ( 177 | 178 |
179 |

Animate

180 |

Current: {data.length} records

181 | 182 | 183 | data={data} 184 | data-id="list" 185 | height={200} 186 | itemHeight={20} 187 | itemKey="id" 188 | // disabled={animating} 189 | ref={listRef} 190 | style={{ 191 | border: '1px solid red', 192 | boxSizing: 'border-box', 193 | }} 194 | // onSkipRender={onAppear} 195 | // onItemRemove={onAppear} 196 | > 197 | {(item, index) => ( 198 | 208 | )} 209 | 210 |
211 |
212 | ); 213 | }; 214 | 215 | export default Demo; 216 | -------------------------------------------------------------------------------- /examples/basic.less: -------------------------------------------------------------------------------- 1 | .fixed-item { 2 | border: 1px solid gray; 3 | padding: 0 16px; 4 | height: 32px; 5 | line-height: 30px; 6 | box-sizing: border-box; 7 | display: inline-block; 8 | } 9 | -------------------------------------------------------------------------------- /examples/basic.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import List, { type ListRef } from '../src/List'; 3 | import './basic.less'; 4 | 5 | interface Item { 6 | id: number; 7 | } 8 | 9 | const MyItem: React.ForwardRefRenderFunction = ({ id }, ref) => ( 10 | { 17 | console.log('Click:', id); 18 | }} 19 | > 20 | {id} 21 | 22 | ); 23 | 24 | const ForwardMyItem = React.forwardRef(MyItem); 25 | 26 | class TestItem extends React.Component { 27 | state = {}; 28 | 29 | render() { 30 | return
{this.props.id}
; 31 | } 32 | } 33 | 34 | const data: Item[] = []; 35 | for (let i = 0; i < 1000; i += 1) { 36 | data.push({ 37 | id: i, 38 | }); 39 | } 40 | 41 | const TYPES = [ 42 | { name: 'ref real dom element', type: 'dom', component: ForwardMyItem }, 43 | { name: 'ref react node', type: 'react', component: TestItem }, 44 | ]; 45 | 46 | const onScroll: React.UIEventHandler = (e) => { 47 | console.log('scroll:', e.currentTarget.scrollTop); 48 | }; 49 | 50 | const Demo = () => { 51 | const [destroy, setDestroy] = React.useState(false); 52 | const [visible, setVisible] = React.useState(true); 53 | const [type, setType] = React.useState('dom'); 54 | const listRef = React.useRef(null); 55 | 56 | return ( 57 | 58 |
59 |

Basic

60 | {TYPES.map(({ name, type: nType }) => ( 61 | 72 | ))} 73 | 74 | 82 | 90 | 101 | 112 | 123 | 134 | 146 | 158 | 169 | 170 | 178 | 179 | 190 | 201 | 202 | 214 | 215 | {!destroy && ( 216 | 230 | {(item, _, props) => 231 | type === 'dom' ? ( 232 | 233 | ) : ( 234 | 235 | ) 236 | } 237 | 238 | )} 239 |
240 |
241 | ); 242 | }; 243 | 244 | export default Demo; 245 | 246 | /* eslint-enable */ 247 | -------------------------------------------------------------------------------- /examples/height.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import List from '../src/List'; 3 | 4 | interface Item { 5 | id: number; 6 | height: number; 7 | } 8 | 9 | const MyItem: React.ForwardRefRenderFunction = ({ id, height }, ref) => { 10 | return ( 11 | 22 | {id} 23 | 24 | ); 25 | }; 26 | 27 | const ForwardMyItem = React.forwardRef(MyItem); 28 | 29 | const data: Item[] = []; 30 | for (let i = 0; i < 100; i += 1) { 31 | data.push({ 32 | id: i, 33 | height: 30 + (i % 2 ? 70 : 0), 34 | }); 35 | } 36 | 37 | const Demo = () => { 38 | return ( 39 | 40 |
41 |

Dynamic Height

42 | 43 | 53 | {item => } 54 | 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default Demo; 61 | -------------------------------------------------------------------------------- /examples/horizontal-scroll.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import List from '../src/List'; 3 | 4 | interface Item { 5 | id: string; 6 | height: number; 7 | } 8 | 9 | const Rect = ({ style }: { style?: React.CSSProperties }) => ( 10 |
21 | Hello 22 |
23 | ); 24 | 25 | const MyItem: React.ForwardRefRenderFunction< 26 | HTMLDivElement, 27 | Item & { style?: React.CSSProperties } 28 | > = (props, ref) => { 29 | const { id, height, style } = props; 30 | 31 | return ( 32 |
46 | 51 |
60 | {id} {'longText '.repeat(100)} 61 |
62 | 67 |
68 | ); 69 | }; 70 | 71 | const ForwardMyItem = React.forwardRef(MyItem); 72 | 73 | function getData(count: number) { 74 | const data: Item[] = []; 75 | for (let i = 0; i < count; i += 1) { 76 | data.push({ 77 | id: `id_${i}`, 78 | height: Math.round(30 + Math.random() * 10), 79 | }); 80 | } 81 | return data; 82 | } 83 | 84 | const Demo = () => { 85 | const [rtl, setRTL] = React.useState(false); 86 | const [count, setCount] = React.useState('1000'); 87 | const [data, setData] = React.useState([]); 88 | 89 | React.useEffect(() => { 90 | const num = Number(count); 91 | if (!Number.isNaN(num)) { 92 | setData(getData(num)); 93 | } 94 | }, [count]); 95 | 96 | return ( 97 | 98 |
99 | 106 | 107 | { 111 | const num = e.target.value; 112 | 113 | setCount(num); 114 | }} 115 | /> 116 | 117 |
118 | { 132 | const { offsetY, rtl: isRTL } = info; 133 | const sizeInfo = info.getSize('id_5', 'id_10'); 134 | 135 | return ( 136 |
146 | Extra 147 |
148 | ); 149 | }} 150 | onVirtualScroll={(e) => { 151 | // console.warn('Scroll:', e); 152 | }} 153 | > 154 | {(item, _, props) => } 155 |
156 |
157 |
158 |
159 | ); 160 | }; 161 | 162 | export default Demo; 163 | -------------------------------------------------------------------------------- /examples/nest.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import List from '../src/List'; 3 | import './basic.less'; 4 | 5 | interface Item { 6 | id: number; 7 | } 8 | 9 | const data: Item[] = []; 10 | for (let i = 0; i < 100; i += 1) { 11 | data.push({ 12 | id: i, 13 | }); 14 | } 15 | 16 | const MyItem: React.ForwardRefRenderFunction = ({ id }, ref) => ( 17 |
18 | 30 | {(item, index, props) => ( 31 |
32 | {id}-{index} 33 |
34 | )} 35 |
36 |
37 | ); 38 | 39 | const ForwardMyItem = React.forwardRef(MyItem); 40 | 41 | const onScroll: React.UIEventHandler = (e) => { 42 | // console.log('scroll:', e.currentTarget.scrollTop); 43 | }; 44 | 45 | const Demo = () => { 46 | return ( 47 | 48 | 61 | {(item, _, props) => } 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default Demo; 68 | 69 | /* eslint-enable */ 70 | -------------------------------------------------------------------------------- /examples/no-virtual.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | import * as React from 'react'; 3 | import List from '../src/List'; 4 | 5 | interface Item { 6 | id: number; 7 | height: number; 8 | } 9 | 10 | const MyItem: React.FC = ({ id, height }, ref) => { 11 | return ( 12 | 23 | {id} 24 | 25 | ); 26 | }; 27 | 28 | const ForwardMyItem = React.forwardRef(MyItem as any); 29 | 30 | const data: Item[] = []; 31 | for (let i = 0; i < 100; i += 1) { 32 | data.push({ 33 | id: i, 34 | height: 30 + (i % 2 ? 20 : 0), 35 | }); 36 | } 37 | 38 | const Demo = () => { 39 | return ( 40 | 41 |
42 |

Not Data

43 | 53 | {item => } 54 | 55 | 56 |

Less Count

57 | 67 | {item => } 68 | 69 | 70 |

Less Item Height

71 | 81 | {item => } 82 | 83 | 84 |

Without Height

85 | 94 | {item => } 95 | 96 |
97 |
98 | ); 99 | }; 100 | 101 | export default Demo; 102 | -------------------------------------------------------------------------------- /examples/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ListRef } from '../src/List'; 3 | import List from '../src/List'; 4 | 5 | interface Item { 6 | id: number; 7 | } 8 | 9 | const MyItem: React.FC = ({ id }, ref) => ( 10 | 22 | {id} 23 | 24 | ); 25 | 26 | const ForwardMyItem = React.forwardRef(MyItem as any); 27 | 28 | function getData(count: number) { 29 | const data: Item[] = []; 30 | for (let i = 0; i < count; i += 1) { 31 | data.push({ 32 | id: i, 33 | }); 34 | } 35 | return data; 36 | } 37 | 38 | const Demo = () => { 39 | const [height, setHeight] = React.useState(200); 40 | const [data, setData] = React.useState(getData(20)); 41 | const [fullHeight, setFullHeight] = React.useState(true); 42 | const listRef = React.useRef(); 43 | 44 | return ( 45 | 46 |
47 |

Switch

48 | { 50 | setData(getData(Number(e.target.value))); 51 | }} 52 | > 53 | Data 54 | 57 | 60 | 64 | 68 | 72 | 76 | 84 | 85 | { 87 | setHeight(Number(e.target.value)); 88 | }} 89 | > 90 | | Height 91 | 94 | 98 | 102 | 103 | 104 | 111 | 112 | 113 | 125 | {(item, _, props) => } 126 | 127 |
128 |
129 | ); 130 | }; 131 | 132 | export default Demo; 133 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-virtual-list", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": "dist" } 9 | } 10 | ], 11 | "routes": [ 12 | { "src": "/(.*)", "dest": "/dist/$1" } 13 | ] 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rc-virtual-list", 3 | "version": "3.18.6", 4 | "description": "React Virtual List Component", 5 | "engines": { 6 | "node": ">=8.x" 7 | }, 8 | "keywords": [ 9 | "react", 10 | "react-component", 11 | "virtual-list" 12 | ], 13 | "homepage": "https://github.com/react-component/virtual-list", 14 | "author": "smith3816@gmail.com", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/react-component/virtual-list.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/react-component/virtual-list/issues" 21 | }, 22 | "files": [ 23 | "lib", 24 | "es", 25 | "assets" 26 | ], 27 | "license": "MIT", 28 | "main": "./lib/index", 29 | "module": "./es/index", 30 | "scripts": { 31 | "start": "dumi dev", 32 | "build": "dumi build", 33 | "compile": "father build", 34 | "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish", 35 | "lint": "eslint src/ --ext .tsx,.ts", 36 | "test": "rc-test", 37 | "now-build": "npm run build" 38 | }, 39 | "peerDependencies": { 40 | "react": ">=16.9.0", 41 | "react-dom": ">=16.9.0" 42 | }, 43 | "devDependencies": { 44 | "@rc-component/father-plugin": "^1.0.2", 45 | "@testing-library/jest-dom": "^5.17.0", 46 | "@testing-library/react": "^12.1.5", 47 | "@types/classnames": "^2.2.10", 48 | "@types/enzyme": "^3.10.5", 49 | "@types/jest": "^25.1.3", 50 | "@types/react": "^18.0.8", 51 | "@types/react-dom": "^18.0.3", 52 | "@types/warning": "^3.0.0", 53 | "cheerio": "1.0.0-rc.12", 54 | "cross-env": "^5.2.0", 55 | "dumi": "^2.2.17", 56 | "enzyme": "^3.1.0", 57 | "enzyme-adapter-react-16": "^1.15.6", 58 | "enzyme-to-json": "^3.1.4", 59 | "eslint": "^8.56.0", 60 | "eslint-plugin-unicorn": "^55.0.0", 61 | "father": "^4.4.0", 62 | "glob": "^7.1.6", 63 | "np": "^5.0.3", 64 | "rc-animate": "^2.9.1", 65 | "rc-test": "^7.0.15", 66 | "react": "16.14.0", 67 | "react-dom": "16.14.0", 68 | "typescript": "^5.0.0" 69 | }, 70 | "dependencies": { 71 | "@babel/runtime": "^7.20.0", 72 | "classnames": "^2.2.6", 73 | "rc-resize-observer": "^1.0.0", 74 | "rc-util": "^5.36.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const WheelLockContext = React.createContext<(lock: boolean) => void>(() => {}); 4 | -------------------------------------------------------------------------------- /src/Filler.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ResizeObserver from 'rc-resize-observer'; 3 | import classNames from 'classnames'; 4 | 5 | export type InnerProps = Pick, 'role' | 'id'>; 6 | 7 | interface FillerProps { 8 | prefixCls?: string; 9 | /** Virtual filler height. Should be `count * itemMinHeight` */ 10 | height: number; 11 | /** Set offset of visible items. Should be the top of start item position */ 12 | offsetY?: number; 13 | offsetX?: number; 14 | 15 | scrollWidth?: number; 16 | 17 | children: React.ReactNode; 18 | 19 | onInnerResize?: () => void; 20 | 21 | innerProps?: InnerProps; 22 | 23 | rtl: boolean; 24 | 25 | extra?: React.ReactNode; 26 | } 27 | 28 | /** 29 | * Fill component to provided the scroll content real height. 30 | */ 31 | const Filler = React.forwardRef( 32 | ( 33 | { 34 | height, 35 | offsetY, 36 | offsetX, 37 | children, 38 | prefixCls, 39 | onInnerResize, 40 | innerProps, 41 | rtl, 42 | extra, 43 | }: FillerProps, 44 | ref: React.Ref, 45 | ) => { 46 | let outerStyle: React.CSSProperties = {}; 47 | 48 | let innerStyle: React.CSSProperties = { 49 | display: 'flex', 50 | flexDirection: 'column', 51 | }; 52 | 53 | if (offsetY !== undefined) { 54 | // Not set `width` since this will break `sticky: right` 55 | outerStyle = { 56 | height, 57 | position: 'relative', 58 | overflow: 'hidden', 59 | }; 60 | 61 | innerStyle = { 62 | ...innerStyle, 63 | transform: `translateY(${offsetY}px)`, 64 | [rtl ? 'marginRight' : 'marginLeft']: -offsetX, 65 | position: 'absolute', 66 | left: 0, 67 | right: 0, 68 | top: 0, 69 | }; 70 | } 71 | 72 | return ( 73 |
74 | { 76 | if (offsetHeight && onInnerResize) { 77 | onInnerResize(); 78 | } 79 | }} 80 | > 81 |
89 | {children} 90 | {extra} 91 |
92 |
93 |
94 | ); 95 | }, 96 | ); 97 | 98 | Filler.displayName = 'Filler'; 99 | 100 | export default Filler; 101 | -------------------------------------------------------------------------------- /src/Item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface ItemProps { 4 | children: React.ReactElement; 5 | setRef: (element: HTMLElement) => void; 6 | } 7 | 8 | export function Item({ children, setRef }: ItemProps) { 9 | const refFunc = React.useCallback(node => { 10 | setRef(node); 11 | }, []); 12 | 13 | return React.cloneElement(children, { 14 | ref: refFunc, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/List.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import type { ResizeObserverProps } from 'rc-resize-observer'; 3 | import ResizeObserver from 'rc-resize-observer'; 4 | import { useEvent } from 'rc-util'; 5 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; 6 | import * as React from 'react'; 7 | import { useRef, useState } from 'react'; 8 | import { flushSync } from 'react-dom'; 9 | import type { InnerProps } from './Filler'; 10 | import Filler from './Filler'; 11 | import useChildren from './hooks/useChildren'; 12 | import useDiffItem from './hooks/useDiffItem'; 13 | import useFrameWheel from './hooks/useFrameWheel'; 14 | import { useGetSize } from './hooks/useGetSize'; 15 | import useHeights from './hooks/useHeights'; 16 | import useMobileTouchMove from './hooks/useMobileTouchMove'; 17 | import useOriginScroll from './hooks/useOriginScroll'; 18 | import useScrollDrag from './hooks/useScrollDrag'; 19 | import type { ScrollPos, ScrollTarget } from './hooks/useScrollTo'; 20 | import useScrollTo from './hooks/useScrollTo'; 21 | import type { ExtraRenderInfo, GetKey, RenderFunc, SharedConfig } from './interface'; 22 | import type { ScrollBarDirectionType, ScrollBarRef } from './ScrollBar'; 23 | import ScrollBar from './ScrollBar'; 24 | import { getSpinSize } from './utils/scrollbarUtil'; 25 | 26 | const EMPTY_DATA = []; 27 | 28 | const ScrollStyle: React.CSSProperties = { 29 | overflowY: 'auto', 30 | overflowAnchor: 'none', 31 | }; 32 | 33 | export interface ScrollInfo { 34 | x: number; 35 | y: number; 36 | } 37 | 38 | export type ScrollConfig = ScrollTarget | ScrollPos; 39 | 40 | export type ScrollTo = (arg: number | ScrollConfig) => void; 41 | 42 | export type ListRef = { 43 | nativeElement: HTMLDivElement; 44 | scrollTo: ScrollTo; 45 | getScrollInfo: () => ScrollInfo; 46 | }; 47 | 48 | export interface ListProps extends Omit, 'children'> { 49 | prefixCls?: string; 50 | children: RenderFunc; 51 | data: T[]; 52 | height?: number; 53 | itemHeight?: number; 54 | /** If not match virtual scroll condition, Set List still use height of container. */ 55 | fullHeight?: boolean; 56 | itemKey: React.Key | ((item: T) => React.Key); 57 | component?: string | React.FC | React.ComponentClass; 58 | /** Set `false` will always use real scroll instead of virtual one */ 59 | virtual?: boolean; 60 | direction?: ScrollBarDirectionType; 61 | /** 62 | * By default `scrollWidth` is same as container. 63 | * When set this, it will show the horizontal scrollbar and 64 | * `scrollWidth` will be used as the real width instead of container width. 65 | * When set, `virtual` will always be enabled. 66 | */ 67 | scrollWidth?: number; 68 | 69 | styles?: { 70 | horizontalScrollBar?: React.CSSProperties; 71 | horizontalScrollBarThumb?: React.CSSProperties; 72 | verticalScrollBar?: React.CSSProperties; 73 | verticalScrollBarThumb?: React.CSSProperties; 74 | }; 75 | showScrollBar?: boolean | 'optional'; 76 | onScroll?: React.UIEventHandler; 77 | 78 | /** 79 | * Given the virtual offset value. 80 | * It's the logic offset from start position. 81 | */ 82 | onVirtualScroll?: (info: ScrollInfo) => void; 83 | 84 | /** Trigger when render list item changed */ 85 | onVisibleChange?: (visibleList: T[], fullList: T[]) => void; 86 | 87 | /** Inject to inner container props. Only use when you need pass aria related data */ 88 | innerProps?: InnerProps; 89 | 90 | /** Render extra content into Filler */ 91 | extraRender?: (info: ExtraRenderInfo) => React.ReactNode; 92 | } 93 | 94 | export function RawList(props: ListProps, ref: React.Ref) { 95 | const { 96 | prefixCls = 'rc-virtual-list', 97 | className, 98 | height, 99 | itemHeight, 100 | fullHeight = true, 101 | style, 102 | data, 103 | children, 104 | itemKey, 105 | virtual, 106 | direction, 107 | scrollWidth, 108 | component: Component = 'div', 109 | onScroll, 110 | onVirtualScroll, 111 | onVisibleChange, 112 | innerProps, 113 | extraRender, 114 | styles, 115 | showScrollBar = 'optional', 116 | ...restProps 117 | } = props; 118 | 119 | // =============================== Item Key =============================== 120 | const getKey = React.useCallback>( 121 | (item: T) => { 122 | if (typeof itemKey === 'function') { 123 | return itemKey(item); 124 | } 125 | return item?.[itemKey as string]; 126 | }, 127 | [itemKey], 128 | ); 129 | 130 | // ================================ Height ================================ 131 | const [setInstanceRef, collectHeight, heights, heightUpdatedMark] = useHeights( 132 | getKey, 133 | null, 134 | null, 135 | ); 136 | 137 | // ================================= MISC ================================= 138 | const useVirtual = !!(virtual !== false && height && itemHeight); 139 | const containerHeight = React.useMemo( 140 | () => Object.values(heights.maps).reduce((total, curr) => total + curr, 0), 141 | [heights.id, heights.maps], 142 | ); 143 | const inVirtual = 144 | useVirtual && 145 | data && 146 | (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth); 147 | const isRTL = direction === 'rtl'; 148 | 149 | const mergedClassName = classNames(prefixCls, { [`${prefixCls}-rtl`]: isRTL }, className); 150 | const mergedData = data || EMPTY_DATA; 151 | const componentRef = useRef(); 152 | const fillerInnerRef = useRef(); 153 | const containerRef = useRef(); 154 | 155 | // =============================== Item Key =============================== 156 | 157 | const [offsetTop, setOffsetTop] = useState(0); 158 | const [offsetLeft, setOffsetLeft] = useState(0); 159 | const [scrollMoving, setScrollMoving] = useState(false); 160 | 161 | const onScrollbarStartMove = () => { 162 | setScrollMoving(true); 163 | }; 164 | const onScrollbarStopMove = () => { 165 | setScrollMoving(false); 166 | }; 167 | 168 | const sharedConfig: SharedConfig = { 169 | getKey, 170 | }; 171 | 172 | // ================================ Scroll ================================ 173 | function syncScrollTop(newTop: number | ((prev: number) => number)) { 174 | setOffsetTop((origin) => { 175 | let value: number; 176 | if (typeof newTop === 'function') { 177 | value = newTop(origin); 178 | } else { 179 | value = newTop; 180 | } 181 | 182 | const alignedTop = keepInRange(value); 183 | 184 | componentRef.current.scrollTop = alignedTop; 185 | return alignedTop; 186 | }); 187 | } 188 | 189 | // ================================ Legacy ================================ 190 | // Put ref here since the range is generate by follow 191 | const rangeRef = useRef({ start: 0, end: mergedData.length }); 192 | 193 | const diffItemRef = useRef(); 194 | const [diffItem] = useDiffItem(mergedData, getKey); 195 | diffItemRef.current = diffItem; 196 | 197 | // ========================== Visible Calculation ========================= 198 | const { 199 | scrollHeight, 200 | start, 201 | end, 202 | offset: fillerOffset, 203 | } = React.useMemo(() => { 204 | if (!useVirtual) { 205 | return { 206 | scrollHeight: undefined, 207 | start: 0, 208 | end: mergedData.length - 1, 209 | offset: undefined, 210 | }; 211 | } 212 | 213 | // Always use virtual scroll bar in avoid shaking 214 | if (!inVirtual) { 215 | return { 216 | scrollHeight: fillerInnerRef.current?.offsetHeight || 0, 217 | start: 0, 218 | end: mergedData.length - 1, 219 | offset: undefined, 220 | }; 221 | } 222 | 223 | let itemTop = 0; 224 | let startIndex: number; 225 | let startOffset: number; 226 | let endIndex: number; 227 | 228 | const dataLen = mergedData.length; 229 | for (let i = 0; i < dataLen; i += 1) { 230 | const item = mergedData[i]; 231 | const key = getKey(item); 232 | 233 | const cacheHeight = heights.get(key); 234 | const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); 235 | 236 | // Check item top in the range 237 | if (currentItemBottom >= offsetTop && startIndex === undefined) { 238 | startIndex = i; 239 | startOffset = itemTop; 240 | } 241 | 242 | // Check item bottom in the range. We will render additional one item for motion usage 243 | if (currentItemBottom > offsetTop + height && endIndex === undefined) { 244 | endIndex = i; 245 | } 246 | 247 | itemTop = currentItemBottom; 248 | } 249 | 250 | // When scrollTop at the end but data cut to small count will reach this 251 | if (startIndex === undefined) { 252 | startIndex = 0; 253 | startOffset = 0; 254 | 255 | endIndex = Math.ceil(height / itemHeight); 256 | } 257 | if (endIndex === undefined) { 258 | endIndex = mergedData.length - 1; 259 | } 260 | 261 | // Give cache to improve scroll experience 262 | endIndex = Math.min(endIndex + 1, mergedData.length - 1); 263 | 264 | return { 265 | scrollHeight: itemTop, 266 | start: startIndex, 267 | end: endIndex, 268 | offset: startOffset, 269 | }; 270 | }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]); 271 | 272 | rangeRef.current.start = start; 273 | rangeRef.current.end = end; 274 | 275 | // When scroll up, first visible item get real height may not same as `itemHeight`, 276 | // Which will make scroll jump. 277 | // Let's sync scroll top to avoid jump 278 | React.useLayoutEffect(() => { 279 | const changedRecord = heights.getRecord(); 280 | if (changedRecord.size === 1) { 281 | const recordKey = Array.from(changedRecord.keys())[0]; 282 | const prevCacheHeight = changedRecord.get(recordKey); 283 | 284 | // Quick switch data may cause `start` not in `mergedData` anymore 285 | const startItem = mergedData[start]; 286 | if (startItem && prevCacheHeight === undefined) { 287 | const startIndexKey = getKey(startItem); 288 | if (startIndexKey === recordKey) { 289 | const realStartHeight = heights.get(recordKey); 290 | const diffHeight = realStartHeight - itemHeight; 291 | syncScrollTop((ori) => { 292 | return ori + diffHeight; 293 | }); 294 | } 295 | } 296 | } 297 | 298 | heights.resetRecord(); 299 | }, [scrollHeight]); 300 | 301 | // ================================= Size ================================= 302 | const [size, setSize] = React.useState({ width: 0, height }); 303 | 304 | const onHolderResize: ResizeObserverProps['onResize'] = (sizeInfo) => { 305 | setSize({ 306 | width: sizeInfo.offsetWidth, 307 | height: sizeInfo.offsetHeight, 308 | }); 309 | }; 310 | 311 | // Hack on scrollbar to enable flash call 312 | const verticalScrollBarRef = useRef(); 313 | const horizontalScrollBarRef = useRef(); 314 | 315 | const horizontalScrollBarSpinSize = React.useMemo( 316 | () => getSpinSize(size.width, scrollWidth), 317 | [size.width, scrollWidth], 318 | ); 319 | const verticalScrollBarSpinSize = React.useMemo( 320 | () => getSpinSize(size.height, scrollHeight), 321 | [size.height, scrollHeight], 322 | ); 323 | 324 | // =============================== In Range =============================== 325 | const maxScrollHeight = scrollHeight - height; 326 | const maxScrollHeightRef = useRef(maxScrollHeight); 327 | maxScrollHeightRef.current = maxScrollHeight; 328 | 329 | function keepInRange(newScrollTop: number) { 330 | let newTop = newScrollTop; 331 | if (!Number.isNaN(maxScrollHeightRef.current)) { 332 | newTop = Math.min(newTop, maxScrollHeightRef.current); 333 | } 334 | newTop = Math.max(newTop, 0); 335 | return newTop; 336 | } 337 | 338 | const isScrollAtTop = offsetTop <= 0; 339 | const isScrollAtBottom = offsetTop >= maxScrollHeight; 340 | const isScrollAtLeft = offsetLeft <= 0; 341 | const isScrollAtRight = offsetLeft >= scrollWidth; 342 | 343 | const originScroll = useOriginScroll( 344 | isScrollAtTop, 345 | isScrollAtBottom, 346 | isScrollAtLeft, 347 | isScrollAtRight, 348 | ); 349 | 350 | // ================================ Scroll ================================ 351 | const getVirtualScrollInfo = () => ({ 352 | x: isRTL ? -offsetLeft : offsetLeft, 353 | y: offsetTop, 354 | }); 355 | 356 | const lastVirtualScrollInfoRef = useRef(getVirtualScrollInfo()); 357 | 358 | const triggerScroll = useEvent((params?: { x?: number; y?: number }) => { 359 | if (onVirtualScroll) { 360 | const nextInfo = { ...getVirtualScrollInfo(), ...params }; 361 | 362 | // Trigger when offset changed 363 | if ( 364 | lastVirtualScrollInfoRef.current.x !== nextInfo.x || 365 | lastVirtualScrollInfoRef.current.y !== nextInfo.y 366 | ) { 367 | onVirtualScroll(nextInfo); 368 | 369 | lastVirtualScrollInfoRef.current = nextInfo; 370 | } 371 | } 372 | }); 373 | 374 | function onScrollBar(newScrollOffset: number, horizontal?: boolean) { 375 | const newOffset = newScrollOffset; 376 | 377 | if (horizontal) { 378 | flushSync(() => { 379 | setOffsetLeft(newOffset); 380 | }); 381 | triggerScroll(); 382 | } else { 383 | syncScrollTop(newOffset); 384 | } 385 | } 386 | 387 | // When data size reduce. It may trigger native scroll event back to fit scroll position 388 | function onFallbackScroll(e: React.UIEvent) { 389 | const { scrollTop: newScrollTop } = e.currentTarget; 390 | if (newScrollTop !== offsetTop) { 391 | syncScrollTop(newScrollTop); 392 | } 393 | 394 | // Trigger origin onScroll 395 | onScroll?.(e); 396 | triggerScroll(); 397 | } 398 | 399 | const keepInHorizontalRange = (nextOffsetLeft: number) => { 400 | let tmpOffsetLeft = nextOffsetLeft; 401 | const max = !!scrollWidth ? scrollWidth - size.width : 0; 402 | tmpOffsetLeft = Math.max(tmpOffsetLeft, 0); 403 | tmpOffsetLeft = Math.min(tmpOffsetLeft, max); 404 | 405 | return tmpOffsetLeft; 406 | }; 407 | 408 | const onWheelDelta: Parameters[6] = useEvent((offsetXY, fromHorizontal) => { 409 | if (fromHorizontal) { 410 | flushSync(() => { 411 | setOffsetLeft((left) => { 412 | const nextOffsetLeft = left + (isRTL ? -offsetXY : offsetXY); 413 | 414 | return keepInHorizontalRange(nextOffsetLeft); 415 | }); 416 | }); 417 | 418 | triggerScroll(); 419 | } else { 420 | syncScrollTop((top) => { 421 | const newTop = top + offsetXY; 422 | 423 | return newTop; 424 | }); 425 | } 426 | }); 427 | 428 | // Since this added in global,should use ref to keep update 429 | const [onRawWheel, onFireFoxScroll] = useFrameWheel( 430 | useVirtual, 431 | isScrollAtTop, 432 | isScrollAtBottom, 433 | isScrollAtLeft, 434 | isScrollAtRight, 435 | !!scrollWidth, 436 | onWheelDelta, 437 | ); 438 | 439 | // Mobile touch move 440 | useMobileTouchMove(useVirtual, componentRef, (isHorizontal, delta, smoothOffset, e) => { 441 | const event = e as TouchEvent & { 442 | _virtualHandled?: boolean; 443 | }; 444 | 445 | if (originScroll(isHorizontal, delta, smoothOffset)) { 446 | return false; 447 | } 448 | 449 | // Fix nest List trigger TouchMove event 450 | if (!event || !event._virtualHandled) { 451 | if (event) { 452 | event._virtualHandled = true; 453 | } 454 | 455 | onRawWheel({ 456 | preventDefault() {}, 457 | deltaX: isHorizontal ? delta : 0, 458 | deltaY: isHorizontal ? 0 : delta, 459 | } as WheelEvent); 460 | 461 | return true; 462 | } 463 | 464 | return false; 465 | }); 466 | 467 | // MouseDown drag for scroll 468 | useScrollDrag(inVirtual, componentRef, (offset) => { 469 | syncScrollTop((top) => top + offset); 470 | }); 471 | 472 | useLayoutEffect(() => { 473 | // Firefox only 474 | function onMozMousePixelScroll(e: WheelEvent) { 475 | // scrolling at top/bottom limit 476 | const scrollingUpAtTop = isScrollAtTop && e.detail < 0; 477 | const scrollingDownAtBottom = isScrollAtBottom && e.detail > 0; 478 | if (useVirtual && !scrollingUpAtTop && !scrollingDownAtBottom) { 479 | e.preventDefault(); 480 | } 481 | } 482 | 483 | const componentEle = componentRef.current; 484 | componentEle.addEventListener('wheel', onRawWheel, { passive: false }); 485 | componentEle.addEventListener('DOMMouseScroll', onFireFoxScroll as any, { passive: true }); 486 | componentEle.addEventListener('MozMousePixelScroll', onMozMousePixelScroll, { passive: false }); 487 | 488 | return () => { 489 | componentEle.removeEventListener('wheel', onRawWheel); 490 | componentEle.removeEventListener('DOMMouseScroll', onFireFoxScroll as any); 491 | componentEle.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll as any); 492 | }; 493 | }, [useVirtual, isScrollAtTop, isScrollAtBottom]); 494 | 495 | // Sync scroll left 496 | useLayoutEffect(() => { 497 | if (scrollWidth) { 498 | const newOffsetLeft = keepInHorizontalRange(offsetLeft); 499 | setOffsetLeft(newOffsetLeft); 500 | triggerScroll({ x: newOffsetLeft }); 501 | } 502 | }, [size.width, scrollWidth]); 503 | 504 | // ================================= Ref ================================== 505 | const delayHideScrollBar = () => { 506 | verticalScrollBarRef.current?.delayHidden(); 507 | horizontalScrollBarRef.current?.delayHidden(); 508 | }; 509 | 510 | const scrollTo = useScrollTo( 511 | componentRef, 512 | mergedData, 513 | heights, 514 | itemHeight, 515 | getKey, 516 | () => collectHeight(true), 517 | syncScrollTop, 518 | delayHideScrollBar, 519 | ); 520 | 521 | React.useImperativeHandle(ref, () => ({ 522 | nativeElement: containerRef.current, 523 | getScrollInfo: getVirtualScrollInfo, 524 | scrollTo: (config) => { 525 | function isPosScroll(arg: any): arg is ScrollPos { 526 | return arg && typeof arg === 'object' && ('left' in arg || 'top' in arg); 527 | } 528 | 529 | if (isPosScroll(config)) { 530 | // Scroll X 531 | if (config.left !== undefined) { 532 | setOffsetLeft(keepInHorizontalRange(config.left)); 533 | } 534 | 535 | // Scroll Y 536 | scrollTo(config.top); 537 | } else { 538 | scrollTo(config); 539 | } 540 | }, 541 | })); 542 | 543 | // ================================ Effect ================================ 544 | /** We need told outside that some list not rendered */ 545 | useLayoutEffect(() => { 546 | if (onVisibleChange) { 547 | const renderList = mergedData.slice(start, end + 1); 548 | 549 | onVisibleChange(renderList, mergedData); 550 | } 551 | }, [start, end, mergedData]); 552 | 553 | // ================================ Extra ================================= 554 | const getSize = useGetSize(mergedData, getKey, heights, itemHeight); 555 | 556 | const extraContent = extraRender?.({ 557 | start, 558 | end, 559 | virtual: inVirtual, 560 | offsetX: offsetLeft, 561 | offsetY: fillerOffset, 562 | rtl: isRTL, 563 | getSize, 564 | }); 565 | 566 | // ================================ Render ================================ 567 | const listChildren = useChildren( 568 | mergedData, 569 | start, 570 | end, 571 | scrollWidth, 572 | offsetLeft, 573 | setInstanceRef, 574 | children, 575 | sharedConfig, 576 | ); 577 | 578 | let componentStyle: React.CSSProperties = null; 579 | if (height) { 580 | componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle }; 581 | 582 | if (useVirtual) { 583 | componentStyle.overflowY = 'hidden'; 584 | 585 | if (scrollWidth) { 586 | componentStyle.overflowX = 'hidden'; 587 | } 588 | 589 | if (scrollMoving) { 590 | componentStyle.pointerEvents = 'none'; 591 | } 592 | } 593 | } 594 | 595 | const containerProps: React.HTMLAttributes = {}; 596 | if (isRTL) { 597 | containerProps.dir = 'rtl'; 598 | } 599 | 600 | return ( 601 |
611 | 612 | 619 | 631 | {listChildren} 632 | 633 | 634 | 635 | 636 | {inVirtual && scrollHeight > height && ( 637 | 652 | )} 653 | 654 | {inVirtual && scrollWidth > size.width && ( 655 | 671 | )} 672 |
673 | ); 674 | } 675 | 676 | const List = React.forwardRef>(RawList); 677 | 678 | List.displayName = 'List'; 679 | 680 | export default List as ( 681 | props: ListProps & { ref?: React.Ref }, 682 | ) => React.ReactElement; 683 | -------------------------------------------------------------------------------- /src/ScrollBar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import raf from 'rc-util/lib/raf'; 3 | import * as React from 'react'; 4 | import { getPageXY } from './hooks/useScrollDrag'; 5 | 6 | export type ScrollBarDirectionType = 'ltr' | 'rtl'; 7 | 8 | export interface ScrollBarProps { 9 | prefixCls: string; 10 | scrollOffset: number; 11 | scrollRange: number; 12 | rtl: boolean; 13 | onScroll: (scrollOffset: number, horizontal?: boolean) => void; 14 | onStartMove: () => void; 15 | onStopMove: () => void; 16 | horizontal?: boolean; 17 | style?: React.CSSProperties; 18 | thumbStyle?: React.CSSProperties; 19 | spinSize: number; 20 | containerSize: number; 21 | showScrollBar?: boolean | 'optional'; 22 | } 23 | 24 | export interface ScrollBarRef { 25 | delayHidden: () => void; 26 | } 27 | 28 | const ScrollBar = React.forwardRef((props, ref) => { 29 | const { 30 | prefixCls, 31 | rtl, 32 | scrollOffset, 33 | scrollRange, 34 | onStartMove, 35 | onStopMove, 36 | onScroll, 37 | horizontal, 38 | spinSize, 39 | containerSize, 40 | style, 41 | thumbStyle: propsThumbStyle, 42 | showScrollBar, 43 | } = props; 44 | 45 | const [dragging, setDragging] = React.useState(false); 46 | const [pageXY, setPageXY] = React.useState(null); 47 | const [startTop, setStartTop] = React.useState(null); 48 | 49 | const isLTR = !rtl; 50 | 51 | // ========================= Refs ========================= 52 | const scrollbarRef = React.useRef(); 53 | const thumbRef = React.useRef(); 54 | 55 | // ======================= Visible ======================== 56 | const [visible, setVisible] = React.useState(showScrollBar); 57 | const visibleTimeoutRef = React.useRef>(); 58 | 59 | const delayHidden = () => { 60 | if (showScrollBar === true || showScrollBar === false) return; 61 | clearTimeout(visibleTimeoutRef.current); 62 | setVisible(true); 63 | visibleTimeoutRef.current = setTimeout(() => { 64 | setVisible(false); 65 | }, 3000); 66 | }; 67 | 68 | // ======================== Range ========================= 69 | const enableScrollRange = scrollRange - containerSize || 0; 70 | const enableOffsetRange = containerSize - spinSize || 0; 71 | 72 | // ========================= Top ========================== 73 | const top = React.useMemo(() => { 74 | if (scrollOffset === 0 || enableScrollRange === 0) { 75 | return 0; 76 | } 77 | const ptg = scrollOffset / enableScrollRange; 78 | return ptg * enableOffsetRange; 79 | }, [scrollOffset, enableScrollRange, enableOffsetRange]); 80 | 81 | // ====================== Container ======================= 82 | const onContainerMouseDown: React.MouseEventHandler = (e) => { 83 | e.stopPropagation(); 84 | e.preventDefault(); 85 | }; 86 | 87 | // ======================== Thumb ========================= 88 | const stateRef = React.useRef({ top, dragging, pageY: pageXY, startTop }); 89 | stateRef.current = { top, dragging, pageY: pageXY, startTop }; 90 | 91 | const onThumbMouseDown = (e: React.MouseEvent | React.TouchEvent | TouchEvent) => { 92 | setDragging(true); 93 | setPageXY(getPageXY(e, horizontal)); 94 | setStartTop(stateRef.current.top); 95 | 96 | onStartMove(); 97 | e.stopPropagation(); 98 | e.preventDefault(); 99 | }; 100 | 101 | // ======================== Effect ======================== 102 | 103 | // React make event as passive, but we need to preventDefault 104 | // Add event on dom directly instead. 105 | // ref: https://github.com/facebook/react/issues/9809 106 | React.useEffect(() => { 107 | const onScrollbarTouchStart = (e: TouchEvent) => { 108 | e.preventDefault(); 109 | }; 110 | 111 | const scrollbarEle = scrollbarRef.current; 112 | const thumbEle = thumbRef.current; 113 | scrollbarEle.addEventListener('touchstart', onScrollbarTouchStart, { passive: false }); 114 | thumbEle.addEventListener('touchstart', onThumbMouseDown, { passive: false }); 115 | 116 | return () => { 117 | scrollbarEle.removeEventListener('touchstart', onScrollbarTouchStart); 118 | thumbEle.removeEventListener('touchstart', onThumbMouseDown); 119 | }; 120 | }, []); 121 | 122 | // Pass to effect 123 | const enableScrollRangeRef = React.useRef(); 124 | enableScrollRangeRef.current = enableScrollRange; 125 | const enableOffsetRangeRef = React.useRef(); 126 | enableOffsetRangeRef.current = enableOffsetRange; 127 | 128 | React.useEffect(() => { 129 | if (dragging) { 130 | let moveRafId: number; 131 | 132 | const onMouseMove = (e: MouseEvent | TouchEvent) => { 133 | const { 134 | dragging: stateDragging, 135 | pageY: statePageY, 136 | startTop: stateStartTop, 137 | } = stateRef.current; 138 | raf.cancel(moveRafId); 139 | 140 | const rect = scrollbarRef.current.getBoundingClientRect(); 141 | const scale = containerSize / (horizontal ? rect.width : rect.height); 142 | 143 | if (stateDragging) { 144 | const offset = (getPageXY(e, horizontal) - statePageY) * scale; 145 | let newTop = stateStartTop; 146 | 147 | if (!isLTR && horizontal) { 148 | newTop -= offset; 149 | } else { 150 | newTop += offset; 151 | } 152 | 153 | const tmpEnableScrollRange = enableScrollRangeRef.current; 154 | const tmpEnableOffsetRange = enableOffsetRangeRef.current; 155 | 156 | const ptg: number = tmpEnableOffsetRange ? newTop / tmpEnableOffsetRange : 0; 157 | 158 | let newScrollTop = Math.ceil(ptg * tmpEnableScrollRange); 159 | newScrollTop = Math.max(newScrollTop, 0); 160 | newScrollTop = Math.min(newScrollTop, tmpEnableScrollRange); 161 | 162 | moveRafId = raf(() => { 163 | onScroll(newScrollTop, horizontal); 164 | }); 165 | } 166 | }; 167 | 168 | const onMouseUp = () => { 169 | setDragging(false); 170 | 171 | onStopMove(); 172 | }; 173 | 174 | window.addEventListener('mousemove', onMouseMove, { passive: true }); 175 | window.addEventListener('touchmove', onMouseMove, { passive: true }); 176 | window.addEventListener('mouseup', onMouseUp, { passive: true }); 177 | window.addEventListener('touchend', onMouseUp, { passive: true }); 178 | 179 | return () => { 180 | window.removeEventListener('mousemove', onMouseMove); 181 | window.removeEventListener('touchmove', onMouseMove); 182 | window.removeEventListener('mouseup', onMouseUp); 183 | window.removeEventListener('touchend', onMouseUp); 184 | 185 | raf.cancel(moveRafId); 186 | }; 187 | } 188 | }, [dragging]); 189 | 190 | React.useEffect(() => { 191 | delayHidden(); 192 | return () => { 193 | clearTimeout(visibleTimeoutRef.current); 194 | }; 195 | }, [scrollOffset]); 196 | 197 | // ====================== Imperative ====================== 198 | React.useImperativeHandle(ref, () => ({ 199 | delayHidden, 200 | })); 201 | 202 | // ======================== Render ======================== 203 | const scrollbarPrefixCls = `${prefixCls}-scrollbar`; 204 | 205 | const containerStyle: React.CSSProperties = { 206 | position: 'absolute', 207 | visibility: visible ? null : 'hidden', 208 | }; 209 | 210 | const thumbStyle: React.CSSProperties = { 211 | position: 'absolute', 212 | background: 'rgba(0, 0, 0, 0.5)', 213 | borderRadius: 99, 214 | cursor: 'pointer', 215 | userSelect: 'none', 216 | }; 217 | 218 | if (horizontal) { 219 | // Container 220 | containerStyle.height = 8; 221 | containerStyle.left = 0; 222 | containerStyle.right = 0; 223 | containerStyle.bottom = 0; 224 | 225 | // Thumb 226 | thumbStyle.height = '100%'; 227 | thumbStyle.width = spinSize; 228 | 229 | if (isLTR) { 230 | thumbStyle.left = top; 231 | } else { 232 | thumbStyle.right = top; 233 | } 234 | } else { 235 | // Container 236 | containerStyle.width = 8; 237 | containerStyle.top = 0; 238 | containerStyle.bottom = 0; 239 | 240 | if (isLTR) { 241 | containerStyle.right = 0; 242 | } else { 243 | containerStyle.left = 0; 244 | } 245 | 246 | // Thumb 247 | thumbStyle.width = '100%'; 248 | thumbStyle.height = spinSize; 249 | thumbStyle.top = top; 250 | } 251 | 252 | return ( 253 |
264 |
272 |
273 | ); 274 | }); 275 | 276 | if (process.env.NODE_ENV !== 'production') { 277 | ScrollBar.displayName = 'ScrollBar'; 278 | } 279 | 280 | export default ScrollBar; 281 | -------------------------------------------------------------------------------- /src/hooks/useChildren.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { RenderFunc, SharedConfig } from '../interface'; 3 | import { Item } from '../Item'; 4 | 5 | export default function useChildren( 6 | list: T[], 7 | startIndex: number, 8 | endIndex: number, 9 | scrollWidth: number, 10 | offsetX: number, 11 | setNodeRef: (item: T, element: HTMLElement) => void, 12 | renderFunc: RenderFunc, 13 | { getKey }: SharedConfig, 14 | ) { 15 | return list.slice(startIndex, endIndex + 1).map((item, index) => { 16 | const eleIndex = startIndex + index; 17 | const node = renderFunc(item, eleIndex, { 18 | style: { 19 | width: scrollWidth, 20 | }, 21 | offsetX, 22 | }) as React.ReactElement; 23 | 24 | const key = getKey(item); 25 | return ( 26 | setNodeRef(item, ele)}> 27 | {node} 28 | 29 | ); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useDiffItem.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { findListDiffIndex } from '../utils/algorithmUtil'; 3 | import type { GetKey } from '../interface'; 4 | 5 | export default function useDiffItem( 6 | data: T[], 7 | getKey: GetKey, 8 | onDiff?: (diffIndex: number) => void, 9 | ): [T] { 10 | const [prevData, setPrevData] = React.useState(data); 11 | const [diffItem, setDiffItem] = React.useState(null); 12 | 13 | React.useEffect(() => { 14 | const diff = findListDiffIndex(prevData || [], data || [], getKey); 15 | if (diff?.index !== undefined) { 16 | onDiff?.(diff.index); 17 | setDiffItem(data[diff.index]); 18 | } 19 | setPrevData(data); 20 | }, [data]); 21 | 22 | return [diffItem]; 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/useFrameWheel.ts: -------------------------------------------------------------------------------- 1 | import raf from 'rc-util/lib/raf'; 2 | import { useRef } from 'react'; 3 | import isFF from '../utils/isFirefox'; 4 | import useOriginScroll from './useOriginScroll'; 5 | 6 | interface FireFoxDOMMouseScrollEvent { 7 | detail: number; 8 | preventDefault: VoidFunction; 9 | } 10 | 11 | export default function useFrameWheel( 12 | inVirtual: boolean, 13 | isScrollAtTop: boolean, 14 | isScrollAtBottom: boolean, 15 | isScrollAtLeft: boolean, 16 | isScrollAtRight: boolean, 17 | horizontalScroll: boolean, 18 | /*** 19 | * Return `true` when you need to prevent default event 20 | */ 21 | onWheelDelta: (offset: number, horizontal: boolean) => void, 22 | ): [(e: WheelEvent) => void, (e: FireFoxDOMMouseScrollEvent) => void] { 23 | const offsetRef = useRef(0); 24 | const nextFrameRef = useRef(null); 25 | 26 | // Firefox patch 27 | const wheelValueRef = useRef(null); 28 | const isMouseScrollRef = useRef(false); 29 | 30 | // Scroll status sync 31 | const originScroll = useOriginScroll( 32 | isScrollAtTop, 33 | isScrollAtBottom, 34 | isScrollAtLeft, 35 | isScrollAtRight, 36 | ); 37 | 38 | function onWheelY(e: WheelEvent, deltaY: number) { 39 | raf.cancel(nextFrameRef.current); 40 | 41 | // Do nothing when scroll at the edge, Skip check when is in scroll 42 | if (originScroll(false, deltaY)) return; 43 | 44 | // Skip if nest List has handled this event 45 | const event = e as WheelEvent & { 46 | _virtualHandled?: boolean; 47 | }; 48 | if (!event._virtualHandled) { 49 | event._virtualHandled = true; 50 | } else { 51 | return; 52 | } 53 | 54 | offsetRef.current += deltaY; 55 | wheelValueRef.current = deltaY; 56 | 57 | // Proxy of scroll events 58 | if (!isFF) { 59 | event.preventDefault(); 60 | } 61 | 62 | nextFrameRef.current = raf(() => { 63 | // Patch a multiple for Firefox to fix wheel number too small 64 | // ref: https://github.com/ant-design/ant-design/issues/26372#issuecomment-679460266 65 | const patchMultiple = isMouseScrollRef.current ? 10 : 1; 66 | onWheelDelta(offsetRef.current * patchMultiple, false); 67 | offsetRef.current = 0; 68 | }); 69 | } 70 | 71 | function onWheelX(event: WheelEvent, deltaX: number) { 72 | onWheelDelta(deltaX, true); 73 | 74 | if (!isFF) { 75 | event.preventDefault(); 76 | } 77 | } 78 | 79 | // Check for which direction does wheel do. `sx` means `shift + wheel` 80 | const wheelDirectionRef = useRef<'x' | 'y' | 'sx' | null>(null); 81 | const wheelDirectionCleanRef = useRef(null); 82 | 83 | function onWheel(event: WheelEvent) { 84 | if (!inVirtual) return; 85 | 86 | // Wait for 2 frame to clean direction 87 | raf.cancel(wheelDirectionCleanRef.current); 88 | wheelDirectionCleanRef.current = raf(() => { 89 | wheelDirectionRef.current = null; 90 | }, 2); 91 | 92 | const { deltaX, deltaY, shiftKey } = event; 93 | 94 | let mergedDeltaX = deltaX; 95 | let mergedDeltaY = deltaY; 96 | 97 | if ( 98 | wheelDirectionRef.current === 'sx' || 99 | (!wheelDirectionRef.current && (shiftKey || false) && deltaY && !deltaX) 100 | ) { 101 | mergedDeltaX = deltaY; 102 | mergedDeltaY = 0; 103 | 104 | wheelDirectionRef.current = 'sx'; 105 | } 106 | 107 | const absX = Math.abs(mergedDeltaX); 108 | const absY = Math.abs(mergedDeltaY); 109 | 110 | if (wheelDirectionRef.current === null) { 111 | wheelDirectionRef.current = horizontalScroll && absX > absY ? 'x' : 'y'; 112 | } 113 | 114 | if (wheelDirectionRef.current === 'y') { 115 | onWheelY(event, mergedDeltaY); 116 | } else { 117 | onWheelX(event, mergedDeltaX); 118 | } 119 | } 120 | 121 | // A patch for firefox 122 | function onFireFoxScroll(event: FireFoxDOMMouseScrollEvent) { 123 | if (!inVirtual) return; 124 | 125 | isMouseScrollRef.current = event.detail === wheelValueRef.current; 126 | } 127 | 128 | return [onWheel, onFireFoxScroll]; 129 | } 130 | -------------------------------------------------------------------------------- /src/hooks/useGetSize.ts: -------------------------------------------------------------------------------- 1 | import type CacheMap from '../utils/CacheMap'; 2 | import type { GetKey, GetSize } from '../interface'; 3 | import * as React from 'react'; 4 | 5 | /** 6 | * Size info need loop query for the `heights` which will has the perf issue. 7 | * Let cache result for each render phase. 8 | */ 9 | export function useGetSize( 10 | mergedData: T[], 11 | getKey: GetKey, 12 | heights: CacheMap, 13 | itemHeight: number, 14 | ) { 15 | const [key2Index, bottomList] = React.useMemo< 16 | [key2Index: Map, bottomList: number[]] 17 | >(() => [new Map(), []], [mergedData, heights.id, itemHeight]); 18 | 19 | const getSize: GetSize = (startKey, endKey = startKey) => { 20 | // Get from cache first 21 | let startIndex = key2Index.get(startKey); 22 | let endIndex = key2Index.get(endKey); 23 | 24 | // Loop to fill the cache 25 | if (startIndex === undefined || endIndex === undefined) { 26 | const dataLen = mergedData.length; 27 | for (let i = bottomList.length; i < dataLen; i += 1) { 28 | const item = mergedData[i]; 29 | const key = getKey(item); 30 | key2Index.set(key, i); 31 | const cacheHeight = heights.get(key) ?? itemHeight; 32 | bottomList[i] = (bottomList[i - 1] || 0) + cacheHeight; 33 | if (key === startKey) { 34 | startIndex = i; 35 | } 36 | if (key === endKey) { 37 | endIndex = i; 38 | } 39 | 40 | if (startIndex !== undefined && endIndex !== undefined) { 41 | break; 42 | } 43 | } 44 | } 45 | 46 | return { 47 | top: bottomList[startIndex - 1] || 0, 48 | bottom: bottomList[endIndex], 49 | }; 50 | }; 51 | 52 | return getSize; 53 | } 54 | -------------------------------------------------------------------------------- /src/hooks/useHeights.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect, useRef } from 'react'; 3 | import type { GetKey } from '../interface'; 4 | import CacheMap from '../utils/CacheMap'; 5 | 6 | function parseNumber(value: string) { 7 | const num = parseFloat(value); 8 | return isNaN(num) ? 0 : num; 9 | } 10 | 11 | export default function useHeights( 12 | getKey: GetKey, 13 | onItemAdd?: (item: T) => void, 14 | onItemRemove?: (item: T) => void, 15 | ): [ 16 | setInstanceRef: (item: T, instance: HTMLElement) => void, 17 | collectHeight: (sync?: boolean) => void, 18 | cacheMap: CacheMap, 19 | updatedMark: number, 20 | ] { 21 | const [updatedMark, setUpdatedMark] = React.useState(0); 22 | const instanceRef = useRef(new Map()); 23 | const heightsRef = useRef(new CacheMap()); 24 | 25 | const promiseIdRef = useRef(0); 26 | 27 | function cancelRaf() { 28 | promiseIdRef.current += 1; 29 | } 30 | 31 | function collectHeight(sync = false) { 32 | cancelRaf(); 33 | 34 | const doCollect = () => { 35 | let changed = false; 36 | 37 | instanceRef.current.forEach((element, key) => { 38 | if (element && element.offsetParent) { 39 | const { offsetHeight } = element; 40 | const { marginTop, marginBottom } = getComputedStyle(element); 41 | 42 | const marginTopNum = parseNumber(marginTop); 43 | const marginBottomNum = parseNumber(marginBottom); 44 | const totalHeight = offsetHeight + marginTopNum + marginBottomNum; 45 | 46 | if (heightsRef.current.get(key) !== totalHeight) { 47 | heightsRef.current.set(key, totalHeight); 48 | changed = true; 49 | } 50 | } 51 | }); 52 | 53 | // Always trigger update mark to tell parent that should re-calculate heights when resized 54 | if (changed) { 55 | setUpdatedMark((c) => c + 1); 56 | } 57 | }; 58 | 59 | if (sync) { 60 | doCollect(); 61 | } else { 62 | promiseIdRef.current += 1; 63 | const id = promiseIdRef.current; 64 | Promise.resolve().then(() => { 65 | if (id === promiseIdRef.current) { 66 | doCollect(); 67 | } 68 | }); 69 | } 70 | } 71 | 72 | function setInstanceRef(item: T, instance: HTMLElement) { 73 | const key = getKey(item); 74 | const origin = instanceRef.current.get(key); 75 | 76 | if (instance) { 77 | instanceRef.current.set(key, instance); 78 | collectHeight(); 79 | } else { 80 | instanceRef.current.delete(key); 81 | } 82 | 83 | // Instance changed 84 | if (!origin !== !instance) { 85 | if (instance) { 86 | onItemAdd?.(item); 87 | } else { 88 | onItemRemove?.(item); 89 | } 90 | } 91 | } 92 | 93 | useEffect(() => { 94 | return cancelRaf; 95 | }, []); 96 | 97 | return [setInstanceRef, collectHeight, heightsRef.current, updatedMark]; 98 | } 99 | -------------------------------------------------------------------------------- /src/hooks/useMobileTouchMove.ts: -------------------------------------------------------------------------------- 1 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; 2 | import type * as React from 'react'; 3 | import { useRef } from 'react'; 4 | 5 | const SMOOTH_PTG = 14 / 15; 6 | 7 | export default function useMobileTouchMove( 8 | inVirtual: boolean, 9 | listRef: React.RefObject, 10 | callback: ( 11 | isHorizontal: boolean, 12 | offset: number, 13 | smoothOffset: boolean, 14 | e?: TouchEvent, 15 | ) => boolean, 16 | ) { 17 | const touchedRef = useRef(false); 18 | const touchXRef = useRef(0); 19 | const touchYRef = useRef(0); 20 | 21 | const elementRef = useRef(null); 22 | 23 | // Smooth scroll 24 | const intervalRef = useRef(null); 25 | 26 | /* eslint-disable prefer-const */ 27 | let cleanUpEvents: () => void; 28 | 29 | const onTouchMove = (e: TouchEvent) => { 30 | if (touchedRef.current) { 31 | const currentX = Math.ceil(e.touches[0].pageX); 32 | const currentY = Math.ceil(e.touches[0].pageY); 33 | let offsetX = touchXRef.current - currentX; 34 | let offsetY = touchYRef.current - currentY; 35 | const isHorizontal = Math.abs(offsetX) > Math.abs(offsetY); 36 | if (isHorizontal) { 37 | touchXRef.current = currentX; 38 | } else { 39 | touchYRef.current = currentY; 40 | } 41 | 42 | const scrollHandled = callback(isHorizontal, isHorizontal ? offsetX : offsetY, false, e); 43 | if (scrollHandled) { 44 | e.preventDefault(); 45 | } 46 | 47 | // Smooth interval 48 | clearInterval(intervalRef.current); 49 | 50 | if (scrollHandled) { 51 | intervalRef.current = setInterval(() => { 52 | if (isHorizontal) { 53 | offsetX *= SMOOTH_PTG; 54 | } else { 55 | offsetY *= SMOOTH_PTG; 56 | } 57 | const offset = Math.floor(isHorizontal ? offsetX : offsetY); 58 | if (!callback(isHorizontal, offset, true) || Math.abs(offset) <= 0.1) { 59 | clearInterval(intervalRef.current); 60 | } 61 | }, 16); 62 | } 63 | } 64 | }; 65 | 66 | const onTouchEnd = () => { 67 | touchedRef.current = false; 68 | 69 | cleanUpEvents(); 70 | }; 71 | 72 | const onTouchStart = (e: TouchEvent) => { 73 | cleanUpEvents(); 74 | 75 | if (e.touches.length === 1 && !touchedRef.current) { 76 | touchedRef.current = true; 77 | touchXRef.current = Math.ceil(e.touches[0].pageX); 78 | touchYRef.current = Math.ceil(e.touches[0].pageY); 79 | 80 | elementRef.current = e.target as HTMLElement; 81 | elementRef.current.addEventListener('touchmove', onTouchMove, { passive: false }); 82 | elementRef.current.addEventListener('touchend', onTouchEnd, { passive: true }); 83 | } 84 | }; 85 | 86 | cleanUpEvents = () => { 87 | if (elementRef.current) { 88 | elementRef.current.removeEventListener('touchmove', onTouchMove); 89 | elementRef.current.removeEventListener('touchend', onTouchEnd); 90 | } 91 | }; 92 | 93 | useLayoutEffect(() => { 94 | if (inVirtual) { 95 | listRef.current.addEventListener('touchstart', onTouchStart, { passive: true }); 96 | } 97 | 98 | return () => { 99 | listRef.current?.removeEventListener('touchstart', onTouchStart); 100 | cleanUpEvents(); 101 | clearInterval(intervalRef.current); 102 | }; 103 | }, [inVirtual]); 104 | } 105 | -------------------------------------------------------------------------------- /src/hooks/useOriginScroll.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | export default ( 4 | isScrollAtTop: boolean, 5 | isScrollAtBottom: boolean, 6 | isScrollAtLeft: boolean, 7 | isScrollAtRight: boolean, 8 | ) => { 9 | // Do lock for a wheel when scrolling 10 | const lockRef = useRef(false); 11 | const lockTimeoutRef = useRef(null); 12 | function lockScroll() { 13 | clearTimeout(lockTimeoutRef.current); 14 | 15 | lockRef.current = true; 16 | 17 | lockTimeoutRef.current = setTimeout(() => { 18 | lockRef.current = false; 19 | }, 50); 20 | } 21 | 22 | // Pass to ref since global add is in closure 23 | const scrollPingRef = useRef({ 24 | top: isScrollAtTop, 25 | bottom: isScrollAtBottom, 26 | left: isScrollAtLeft, 27 | right: isScrollAtRight, 28 | }); 29 | scrollPingRef.current.top = isScrollAtTop; 30 | scrollPingRef.current.bottom = isScrollAtBottom; 31 | scrollPingRef.current.left = isScrollAtLeft; 32 | scrollPingRef.current.right = isScrollAtRight; 33 | 34 | return (isHorizontal: boolean, delta: number, smoothOffset = false) => { 35 | const originScroll = isHorizontal 36 | ? // Pass origin wheel when on the left 37 | (delta < 0 && scrollPingRef.current.left) || 38 | // Pass origin wheel when on the right 39 | (delta > 0 && scrollPingRef.current.right) // Pass origin wheel when on the top 40 | : (delta < 0 && scrollPingRef.current.top) || 41 | // Pass origin wheel when on the bottom 42 | (delta > 0 && scrollPingRef.current.bottom); 43 | 44 | if (smoothOffset && originScroll) { 45 | // No need lock anymore when it's smooth offset from touchMove interval 46 | clearTimeout(lockTimeoutRef.current); 47 | lockRef.current = false; 48 | } else if (!originScroll || lockRef.current) { 49 | lockScroll(); 50 | } 51 | 52 | return !lockRef.current && originScroll; 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/hooks/useScrollDrag.ts: -------------------------------------------------------------------------------- 1 | import raf from 'rc-util/lib/raf'; 2 | import * as React from 'react'; 3 | 4 | function smoothScrollOffset(offset: number) { 5 | return Math.floor(offset ** 0.5); 6 | } 7 | 8 | export function getPageXY( 9 | e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent, 10 | horizontal: boolean, 11 | ) { 12 | const obj = 'touches' in e ? e.touches[0] : e; 13 | return obj[horizontal ? 'pageX' : 'pageY'] - window[horizontal ? 'scrollX' : 'scrollY']; 14 | } 15 | 16 | export default function useScrollDrag( 17 | inVirtual: boolean, 18 | componentRef: React.RefObject, 19 | onScrollOffset: (offset: number) => void, 20 | ) { 21 | React.useEffect(() => { 22 | const ele = componentRef.current; 23 | if (inVirtual && ele) { 24 | let mouseDownLock = false; 25 | let rafId: number; 26 | let offset: number; 27 | 28 | const stopScroll = () => { 29 | raf.cancel(rafId); 30 | }; 31 | 32 | const continueScroll = () => { 33 | stopScroll(); 34 | 35 | rafId = raf(() => { 36 | onScrollOffset(offset); 37 | continueScroll(); 38 | }); 39 | }; 40 | 41 | const onMouseDown = (e: MouseEvent) => { 42 | // Skip if element set draggable 43 | if ((e.target as HTMLElement).draggable || e.button !== 0) { 44 | return; 45 | } 46 | // Skip if nest List has handled this event 47 | const event = e as MouseEvent & { 48 | _virtualHandled?: boolean; 49 | }; 50 | if (!event._virtualHandled) { 51 | event._virtualHandled = true; 52 | mouseDownLock = true; 53 | } 54 | }; 55 | const onMouseUp = () => { 56 | mouseDownLock = false; 57 | stopScroll(); 58 | }; 59 | const onMouseMove = (e: MouseEvent) => { 60 | if (mouseDownLock) { 61 | const mouseY = getPageXY(e, false); 62 | const { top, bottom } = ele.getBoundingClientRect(); 63 | 64 | if (mouseY <= top) { 65 | const diff = top - mouseY; 66 | offset = -smoothScrollOffset(diff); 67 | continueScroll(); 68 | } else if (mouseY >= bottom) { 69 | const diff = mouseY - bottom; 70 | offset = smoothScrollOffset(diff); 71 | continueScroll(); 72 | } else { 73 | stopScroll(); 74 | } 75 | } 76 | }; 77 | 78 | ele.addEventListener('mousedown', onMouseDown); 79 | ele.ownerDocument.addEventListener('mouseup', onMouseUp); 80 | ele.ownerDocument.addEventListener('mousemove', onMouseMove); 81 | 82 | return () => { 83 | ele.removeEventListener('mousedown', onMouseDown); 84 | ele.ownerDocument.removeEventListener('mouseup', onMouseUp); 85 | ele.ownerDocument.removeEventListener('mousemove', onMouseMove); 86 | stopScroll(); 87 | }; 88 | } 89 | }, [inVirtual]); 90 | } 91 | -------------------------------------------------------------------------------- /src/hooks/useScrollTo.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import * as React from 'react'; 3 | import raf from 'rc-util/lib/raf'; 4 | import type { GetKey } from '../interface'; 5 | import type CacheMap from '../utils/CacheMap'; 6 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; 7 | import { warning } from 'rc-util'; 8 | 9 | const MAX_TIMES = 10; 10 | 11 | export type ScrollAlign = 'top' | 'bottom' | 'auto'; 12 | 13 | export type ScrollPos = { 14 | left?: number; 15 | top?: number; 16 | }; 17 | 18 | export type ScrollTarget = 19 | | { 20 | index: number; 21 | align?: ScrollAlign; 22 | offset?: number; 23 | } 24 | | { 25 | key: React.Key; 26 | align?: ScrollAlign; 27 | offset?: number; 28 | }; 29 | 30 | export default function useScrollTo( 31 | containerRef: React.RefObject, 32 | data: T[], 33 | heights: CacheMap, 34 | itemHeight: number, 35 | getKey: GetKey, 36 | collectHeight: () => void, 37 | syncScrollTop: (newTop: number) => void, 38 | triggerFlash: () => void, 39 | ): (arg: number | ScrollTarget) => void { 40 | const scrollRef = React.useRef(); 41 | 42 | const [syncState, setSyncState] = React.useState<{ 43 | times: number; 44 | index: number; 45 | offset: number; 46 | originAlign: ScrollAlign; 47 | targetAlign?: 'top' | 'bottom'; 48 | lastTop?: number; 49 | }>(null); 50 | 51 | // ========================== Sync Scroll ========================== 52 | useLayoutEffect(() => { 53 | if (syncState && syncState.times < MAX_TIMES) { 54 | // Never reach 55 | if (!containerRef.current) { 56 | setSyncState((ori) => ({ ...ori })); 57 | return; 58 | } 59 | 60 | collectHeight(); 61 | 62 | const { targetAlign, originAlign, index, offset } = syncState; 63 | 64 | const height = containerRef.current.clientHeight; 65 | let needCollectHeight = false; 66 | let newTargetAlign: 'top' | 'bottom' | null = targetAlign; 67 | let targetTop: number | null = null; 68 | 69 | // Go to next frame if height not exist 70 | if (height) { 71 | const mergedAlign = targetAlign || originAlign; 72 | 73 | // Get top & bottom 74 | let stackTop = 0; 75 | let itemTop = 0; 76 | let itemBottom = 0; 77 | 78 | const maxLen = Math.min(data.length - 1, index); 79 | 80 | for (let i = 0; i <= maxLen; i += 1) { 81 | const key = getKey(data[i]); 82 | itemTop = stackTop; 83 | const cacheHeight = heights.get(key); 84 | itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); 85 | 86 | stackTop = itemBottom; 87 | } 88 | 89 | // Check if need sync height (visible range has item not record height) 90 | let leftHeight = mergedAlign === 'top' ? offset : height - offset; 91 | for (let i = maxLen; i >= 0; i -= 1) { 92 | const key = getKey(data[i]); 93 | const cacheHeight = heights.get(key); 94 | 95 | if (cacheHeight === undefined) { 96 | needCollectHeight = true; 97 | break; 98 | } 99 | 100 | leftHeight -= cacheHeight; 101 | if (leftHeight <= 0) { 102 | break; 103 | } 104 | } 105 | 106 | // Scroll to 107 | switch (mergedAlign) { 108 | case 'top': 109 | targetTop = itemTop - offset; 110 | break; 111 | case 'bottom': 112 | targetTop = itemBottom - height + offset; 113 | break; 114 | 115 | default: { 116 | const { scrollTop } = containerRef.current; 117 | const scrollBottom = scrollTop + height; 118 | if (itemTop < scrollTop) { 119 | newTargetAlign = 'top'; 120 | } else if (itemBottom > scrollBottom) { 121 | newTargetAlign = 'bottom'; 122 | } 123 | } 124 | } 125 | 126 | if (targetTop !== null) { 127 | syncScrollTop(targetTop); 128 | } 129 | 130 | // One more time for sync 131 | if (targetTop !== syncState.lastTop) { 132 | needCollectHeight = true; 133 | } 134 | } 135 | 136 | // Trigger next effect 137 | if (needCollectHeight) { 138 | setSyncState({ 139 | ...syncState, 140 | times: syncState.times + 1, 141 | targetAlign: newTargetAlign, 142 | lastTop: targetTop, 143 | }); 144 | } 145 | } else if (process.env.NODE_ENV !== 'production' && syncState?.times === MAX_TIMES) { 146 | warning( 147 | false, 148 | 'Seems `scrollTo` with `rc-virtual-list` reach the max limitation. Please fire issue for us. Thanks.', 149 | ); 150 | } 151 | }, [syncState, containerRef.current]); 152 | 153 | // =========================== Scroll To =========================== 154 | return (arg) => { 155 | // When not argument provided, we think dev may want to show the scrollbar 156 | if (arg === null || arg === undefined) { 157 | triggerFlash(); 158 | return; 159 | } 160 | 161 | // Normal scroll logic 162 | raf.cancel(scrollRef.current); 163 | 164 | if (typeof arg === 'number') { 165 | syncScrollTop(arg); 166 | } else if (arg && typeof arg === 'object') { 167 | let index: number; 168 | const { align } = arg; 169 | 170 | if ('index' in arg) { 171 | ({ index } = arg); 172 | } else { 173 | index = data.findIndex((item) => getKey(item) === arg.key); 174 | } 175 | 176 | const { offset = 0 } = arg; 177 | 178 | setSyncState({ 179 | times: 0, 180 | index, 181 | offset, 182 | originAlign: align, 183 | }); 184 | } 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import List from './List'; 2 | 3 | export type { ListRef, ListProps } from './List'; 4 | 5 | export default List; 6 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | export type RenderFunc = ( 2 | item: T, 3 | index: number, 4 | props: { style: React.CSSProperties; offsetX: number }, 5 | ) => React.ReactNode; 6 | 7 | export interface SharedConfig { 8 | getKey: (item: T) => React.Key; 9 | } 10 | 11 | export type GetKey = (item: T) => React.Key; 12 | 13 | export type GetSize = (startKey: React.Key, endKey?: React.Key) => { top: number; bottom: number }; 14 | 15 | export interface ExtraRenderInfo { 16 | /** Virtual list start line */ 17 | start: number; 18 | /** Virtual list end line */ 19 | end: number; 20 | /** Is current in virtual render */ 21 | virtual: boolean; 22 | /** Used for `scrollWidth` tell the horizontal offset */ 23 | offsetX: number; 24 | offsetY: number; 25 | 26 | rtl: boolean; 27 | 28 | getSize: GetSize; 29 | } 30 | -------------------------------------------------------------------------------- /src/mock.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ListProps, ListRef } from './List'; 3 | import { RawList } from './List'; 4 | 5 | const List = React.forwardRef((props: ListProps, ref: React.Ref) => 6 | RawList({ ...props, virtual: false }, ref), 7 | ) as ( 8 | props: React.PropsWithChildren> & { ref?: React.Ref }, 9 | ) => React.ReactElement; 10 | 11 | (List as any).displayName = 'List'; 12 | 13 | export default List; 14 | -------------------------------------------------------------------------------- /src/utils/CacheMap.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | // Firefox has low performance of map. 4 | class CacheMap { 5 | maps: Record; 6 | 7 | // Used for cache key 8 | // `useMemo` no need to update if `id` not change 9 | id: number = 0; 10 | 11 | diffRecords = new Map(); 12 | 13 | constructor() { 14 | this.maps = Object.create(null); 15 | } 16 | 17 | set(key: React.Key, value: number) { 18 | // Record prev value 19 | this.diffRecords.set(key, this.maps[key as string]); 20 | 21 | this.maps[key as string] = value; 22 | this.id += 1; 23 | } 24 | 25 | get(key: React.Key) { 26 | return this.maps[key as string]; 27 | } 28 | 29 | /** 30 | * CacheMap will record the key changed. 31 | * To help to know what's update in the next render. 32 | */ 33 | resetRecord() { 34 | this.diffRecords.clear(); 35 | } 36 | 37 | getRecord() { 38 | return this.diffRecords; 39 | } 40 | } 41 | 42 | export default CacheMap; 43 | -------------------------------------------------------------------------------- /src/utils/algorithmUtil.ts: -------------------------------------------------------------------------------- 1 | import type * as React from 'react'; 2 | /** 3 | * Get index with specific start index one by one. e.g. 4 | * min: 3, max: 9, start: 6 5 | * 6 | * Return index is: 7 | * [0]: 6 8 | * [1]: 7 9 | * [2]: 5 10 | * [3]: 8 11 | * [4]: 4 12 | * [5]: 9 13 | * [6]: 3 14 | */ 15 | export function getIndexByStartLoc(min: number, max: number, start: number, index: number): number { 16 | const beforeCount = start - min; 17 | const afterCount = max - start; 18 | const balanceCount = Math.min(beforeCount, afterCount) * 2; 19 | 20 | // Balance 21 | if (index <= balanceCount) { 22 | const stepIndex = Math.floor(index / 2); 23 | if (index % 2) { 24 | return start + stepIndex + 1; 25 | } 26 | return start - stepIndex; 27 | } 28 | 29 | // One is out of range 30 | if (beforeCount > afterCount) { 31 | return start - (index - afterCount); 32 | } 33 | return start + (index - beforeCount); 34 | } 35 | 36 | /** 37 | * We assume that 2 list has only 1 item diff and others keeping the order. 38 | * So we can use dichotomy algorithm to find changed one. 39 | */ 40 | export function findListDiffIndex( 41 | originList: T[], 42 | targetList: T[], 43 | getKey: (item: T) => React.Key, 44 | ): { index: number; multiple: boolean } | null { 45 | const originLen = originList.length; 46 | const targetLen = targetList.length; 47 | 48 | let shortList: T[]; 49 | let longList: T[]; 50 | 51 | if (originLen === 0 && targetLen === 0) { 52 | return null; 53 | } 54 | 55 | if (originLen < targetLen) { 56 | shortList = originList; 57 | longList = targetList; 58 | } else { 59 | shortList = targetList; 60 | longList = originList; 61 | } 62 | 63 | const notExistKey = { __EMPTY_ITEM__: true }; 64 | function getItemKey(item: T) { 65 | if (item !== undefined) { 66 | return getKey(item); 67 | } 68 | return notExistKey; 69 | } 70 | 71 | // Loop to find diff one 72 | let diffIndex: number = null; 73 | let multiple = Math.abs(originLen - targetLen) !== 1; 74 | for (let i = 0; i < longList.length; i += 1) { 75 | const shortKey = getItemKey(shortList[i]); 76 | const longKey = getItemKey(longList[i]); 77 | 78 | if (shortKey !== longKey) { 79 | diffIndex = i; 80 | multiple = multiple || shortKey !== getItemKey(longList[i + 1]); 81 | break; 82 | } 83 | } 84 | 85 | return diffIndex === null ? null : { index: diffIndex, multiple }; 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/isFirefox.ts: -------------------------------------------------------------------------------- 1 | const isFF = typeof navigator === 'object' && /Firefox/i.test(navigator.userAgent); 2 | 3 | export default isFF; 4 | -------------------------------------------------------------------------------- /src/utils/scrollbarUtil.ts: -------------------------------------------------------------------------------- 1 | const MIN_SIZE = 20; 2 | 3 | export function getSpinSize(containerSize = 0, scrollRange = 0) { 4 | let baseSize = (containerSize / scrollRange) * containerSize; 5 | if (isNaN(baseSize)) { 6 | baseSize = 0; 7 | } 8 | baseSize = Math.max(baseSize, MIN_SIZE); 9 | return Math.floor(baseSize); 10 | } 11 | -------------------------------------------------------------------------------- /tests/__mocks__/rc-util/lib/raf.ts: -------------------------------------------------------------------------------- 1 | function raf(callback: Function) { 2 | return setTimeout(callback); 3 | } 4 | 5 | raf.cancel = (id: number) => { 6 | clearTimeout(id); 7 | }; 8 | 9 | export default raf; 10 | -------------------------------------------------------------------------------- /tests/list.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import List from '../src'; 5 | import Filler from '../src/Filler'; 6 | import { spyElementPrototypes } from './utils/domHook'; 7 | 8 | function genData(count) { 9 | return new Array(count).fill(null).map((_, id) => ({ id })); 10 | } 11 | 12 | describe('List.Basic', () => { 13 | function genList(props) { 14 | let node = ( 15 | 16 | {({ id }) =>
  • {id}
  • } 17 |
    18 | ); 19 | 20 | return mount(node); 21 | } 22 | 23 | describe('raw', () => { 24 | it('without height', () => { 25 | const wrapper = genList({ data: genData(1) }); 26 | expect(wrapper.find(Filler).props().offset).toBeFalsy(); 27 | }); 28 | 29 | describe('height over itemHeight', () => { 30 | it('full height', () => { 31 | const wrapper = genList({ data: genData(1), itemHeight: 1, height: 999 }); 32 | expect(wrapper.find(Filler).props().offset).toBeFalsy(); 33 | expect(wrapper.find('ul').props().style).toEqual(expect.objectContaining({ height: 999 })); 34 | }); 35 | 36 | it('without full height', () => { 37 | const wrapper = genList({ 38 | data: genData(1), 39 | itemHeight: 1, 40 | height: 999, 41 | fullHeight: false, 42 | }); 43 | expect(wrapper.find(Filler).props().offset).toBeFalsy(); 44 | expect(wrapper.find('ul').props().style).toEqual( 45 | expect.objectContaining({ maxHeight: 999 }), 46 | ); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('virtual', () => { 52 | let scrollTop = 0; 53 | let mockElement; 54 | 55 | beforeAll(() => { 56 | mockElement = spyElementPrototypes(HTMLElement, { 57 | offsetHeight: { 58 | get: () => 20, 59 | }, 60 | scrollHeight: { 61 | get: () => 2000, 62 | }, 63 | clientHeight: { 64 | get: () => 100, 65 | }, 66 | scrollTop: { 67 | get: () => scrollTop, 68 | set(_, val) { 69 | scrollTop = val; 70 | }, 71 | }, 72 | }); 73 | }); 74 | 75 | afterAll(() => { 76 | mockElement.mockRestore(); 77 | }); 78 | 79 | it('scroll it', () => { 80 | const onVisibleChange = jest.fn(); 81 | 82 | // scroll to top 83 | scrollTop = 0; 84 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), onVisibleChange }); 85 | expect(wrapper.find(Filler).props().height).toEqual(2000); 86 | expect(wrapper.find(Filler).props().offsetY).toEqual(0); 87 | onVisibleChange.mockReset(); 88 | 89 | // scrollTop to end 90 | scrollTop = 2000 - 100; 91 | wrapper.find('ul').simulate('scroll', { 92 | scrollTop, 93 | }); 94 | expect(wrapper.find(Filler).props().height).toEqual(2000); 95 | expect(wrapper.find(Filler).props().offsetY + wrapper.find('li').length * 20).toEqual(2000); 96 | 97 | expect(onVisibleChange.mock.calls[0][0]).toHaveLength(6); 98 | expect(onVisibleChange.mock.calls[0][1]).toHaveLength(100); 99 | }); 100 | }); 101 | 102 | describe('status switch', () => { 103 | let scrollTop = 0; 104 | 105 | let mockLiElement; 106 | let mockElement; 107 | 108 | beforeAll(() => { 109 | mockLiElement = spyElementPrototypes(HTMLLIElement, { 110 | offsetHeight: { 111 | get: () => 40, 112 | }, 113 | }); 114 | 115 | mockElement = spyElementPrototypes(HTMLElement, { 116 | clientHeight: { 117 | get: () => 100, 118 | }, 119 | scrollTop: { 120 | get: () => scrollTop, 121 | set(_, val) { 122 | scrollTop = val; 123 | }, 124 | }, 125 | }); 126 | }); 127 | 128 | afterAll(() => { 129 | mockElement.mockRestore(); 130 | mockLiElement.mockRestore(); 131 | }); 132 | 133 | it('raw to virtual', () => { 134 | let data = genData(5); 135 | const wrapper = genList({ itemHeight: 20, height: 100, data }); 136 | 137 | expect(wrapper.find('li')).toHaveLength(5); 138 | 139 | data = genData(10); 140 | wrapper.setProps({ data }); 141 | expect(wrapper.find('li').length < data.length).toBeTruthy(); 142 | }); 143 | 144 | it('virtual to raw', () => { 145 | let data = genData(10); 146 | const wrapper = genList({ itemHeight: 20, height: 100, data }); 147 | expect(wrapper.find('li').length < data.length).toBeTruthy(); 148 | 149 | data = data.slice(0, 2); 150 | wrapper.setProps({ data }); 151 | expect(wrapper.find('li')).toHaveLength(2); 152 | 153 | // Should not crash if data count change 154 | data = data.slice(0, 1); 155 | wrapper.setProps({ data }); 156 | expect(wrapper.find('li')).toHaveLength(1); 157 | }); 158 | }); 159 | 160 | it('`virtual` is false', () => { 161 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), virtual: false }); 162 | expect(wrapper.find('li')).toHaveLength(100); 163 | }); 164 | 165 | it('Should not crash when height change makes virtual scroll to be raw scroll', () => { 166 | const wrapper = genList({ itemHeight: 20, height: 40, data: genData(3) }); 167 | wrapper.setProps({ height: 1000 }); 168 | }); 169 | 170 | describe('should collect height', () => { 171 | let mockElement; 172 | let collected = false; 173 | 174 | beforeAll(() => { 175 | mockElement = spyElementPrototypes(HTMLElement, { 176 | offsetHeight: { 177 | get: () => { 178 | collected = true; 179 | return 20; 180 | }, 181 | }, 182 | offsetParent: { 183 | get() { 184 | return this; 185 | }, 186 | }, 187 | }); 188 | }); 189 | 190 | afterAll(() => { 191 | mockElement.mockRestore(); 192 | }); 193 | 194 | it('work', async () => { 195 | const wrapper = genList({ itemHeight: 20, height: 40, data: genData(3) }); 196 | wrapper.find('Filler').find('ResizeObserver').props().onResize({ offsetHeight: 0 }); 197 | expect(collected).toBeFalsy(); 198 | 199 | // Wait for collection 200 | await act(async () => { 201 | await new Promise((resolve) => { 202 | setTimeout(resolve, 10); 203 | }); 204 | }); 205 | 206 | wrapper.find('Filler').find('ResizeObserver').props().onResize({ offsetHeight: 100 }); 207 | expect(collected).toBeTruthy(); 208 | }); 209 | }); 210 | 211 | it('innerProps', () => { 212 | const wrapper = genList({ 213 | itemHeight: 20, 214 | height: 100, 215 | data: genData(100), 216 | virtual: false, 217 | innerProps: { 218 | role: 'listbox', 219 | id: `my_list`, 220 | }, 221 | }); 222 | 223 | expect(wrapper.find('div#my_list').prop('role')).toEqual('listbox'); 224 | }); 225 | 226 | it('nativeElement', () => { 227 | const ref = React.createRef(); 228 | const wrapper = genList({ data: genData(1), ref }); 229 | expect(ref.current.nativeElement).toBe(wrapper.getDOMNode()); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /tests/mock.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import MockList from '../src/mock'; 4 | import Filler from '../src/Filler'; 5 | 6 | describe('MockList', () => { 7 | it('correct render', () => { 8 | const wrapper = mount( 9 | id}> 10 | {id => {id}} 11 | , 12 | ); 13 | 14 | expect(wrapper.find(Filler).length).toBeTruthy(); 15 | 16 | for (let i = 0; i < 3; i += 1) { 17 | expect( 18 | wrapper 19 | .find('Item') 20 | .at(i) 21 | .key(), 22 | ).toBe(String(i)); 23 | } 24 | 25 | expect(wrapper.find('List')).toHaveLength(1); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/props.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import List from '../src'; 4 | 5 | describe('Props', () => { 6 | it('itemKey is a function', () => { 7 | class ItemComponent extends React.Component { 8 | render() { 9 | return this.props.children; 10 | } 11 | } 12 | 13 | const wrapper = mount( 14 | item.id}> 15 | {({ id }) => {id}} 16 | , 17 | ); 18 | 19 | expect( 20 | wrapper 21 | .find('Item') 22 | .at(0) 23 | .key(), 24 | ).toBe('903'); 25 | 26 | expect( 27 | wrapper 28 | .find('Item') 29 | .at(1) 30 | .key(), 31 | ).toBe('1128'); 32 | }); 33 | 34 | it('prefixCls', () => { 35 | const wrapper = mount( 36 | id} prefixCls="prefix"> 37 | {id =>
    {id}
    } 38 |
    , 39 | ); 40 | 41 | expect(wrapper.find('.prefix-holder-inner').length).toBeTruthy(); 42 | }); 43 | 44 | it('offsetX in renderFn', () => { 45 | let scrollLeft; 46 | mount( 47 | id} prefixCls="prefix"> 48 | {(id, _, { offsetX }) => { 49 | scrollLeft = offsetX; 50 | return
    {id}
    }} 51 |
    , 52 | ); 53 | 54 | expect(scrollLeft).toEqual(0); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/scroll-Firefox.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import { mount } from 'enzyme'; 4 | import { spyElementPrototypes } from './utils/domHook'; 5 | import List from '../src'; 6 | import isFF from '../src/utils/isFirefox'; 7 | 8 | function genData(count) { 9 | return new Array(count).fill(null).map((_, index) => ({ id: String(index) })); 10 | } 11 | 12 | jest.mock('../src/utils/isFirefox', () => true); 13 | 14 | describe('List.Firefox-Scroll', () => { 15 | let mockElement; 16 | 17 | beforeAll(() => { 18 | mockElement = spyElementPrototypes(HTMLElement, { 19 | offsetHeight: { 20 | get: () => 20, 21 | }, 22 | clientHeight: { 23 | get: () => 100, 24 | }, 25 | }); 26 | }); 27 | 28 | afterAll(() => { 29 | mockElement.mockRestore(); 30 | }); 31 | 32 | beforeEach(() => { 33 | jest.useFakeTimers(); 34 | }); 35 | 36 | afterEach(() => { 37 | jest.useRealTimers(); 38 | }); 39 | 40 | function genList(props) { 41 | let node = ( 42 | 43 | {({ id }) =>
  • {id}
  • } 44 |
    45 | ); 46 | 47 | if (props.ref) { 48 | node =
    {node}
    ; 49 | } 50 | 51 | return mount(node); 52 | } 53 | 54 | it('should be true', () => { 55 | expect(isFF).toBe(true); 56 | }); 57 | 58 | // https://github.com/ant-design/ant-design/issues/26372 59 | it('FireFox should patch scroll speed', () => { 60 | const wheelPreventDefault = jest.fn(); 61 | const firefoxPreventDefault = jest.fn(); 62 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 63 | const ulElement = wrapper.find('ul').instance(); 64 | 65 | act(() => { 66 | const wheelEvent = new Event('wheel'); 67 | wheelEvent.deltaY = 3; 68 | wheelEvent.preventDefault = wheelPreventDefault; 69 | ulElement.dispatchEvent(wheelEvent); 70 | 71 | const firefoxPixelScrollEvent = new Event('MozMousePixelScroll'); 72 | firefoxPixelScrollEvent.detail = 6; 73 | firefoxPixelScrollEvent.preventDefault = firefoxPreventDefault; 74 | ulElement.dispatchEvent(firefoxPixelScrollEvent); 75 | 76 | const firefoxScrollEvent = new Event('DOMMouseScroll'); 77 | firefoxScrollEvent.detail = 3; 78 | firefoxScrollEvent.preventDefault = firefoxPreventDefault; 79 | ulElement.dispatchEvent(firefoxScrollEvent); 80 | 81 | jest.runAllTimers(); 82 | }); 83 | 84 | expect(wheelPreventDefault).not.toHaveBeenCalled(); 85 | expect(firefoxPreventDefault).toHaveBeenCalledTimes(1); 86 | }); 87 | 88 | it('should call preventDefault on MozMousePixelScroll', () => { 89 | const preventDefault = jest.fn(); 90 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 91 | const ulElement = wrapper.find('ul').instance(); 92 | 93 | act(() => { 94 | const event = new Event('MozMousePixelScroll'); 95 | event.detail = 6; 96 | event.preventDefault = preventDefault; 97 | ulElement.dispatchEvent(event); 98 | 99 | jest.runAllTimers(); 100 | }); 101 | 102 | expect(preventDefault).toHaveBeenCalled(); 103 | }); 104 | 105 | it('should not call preventDefault on MozMousePixelScroll when scrolling up at top boundary', () => { 106 | const preventDefault = jest.fn(); 107 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 108 | const ulElement = wrapper.find('ul').instance(); 109 | 110 | act(() => { 111 | const event = new Event('MozMousePixelScroll'); 112 | event.detail = -6; 113 | event.preventDefault = preventDefault; 114 | ulElement.dispatchEvent(event); 115 | 116 | jest.runAllTimers(); 117 | }); 118 | 119 | expect(preventDefault).not.toHaveBeenCalled(); 120 | }); 121 | it('should not call preventDefault on MozMousePixelScroll when scrolling down at bottom boundary', () => { 122 | const preventDefault = jest.fn(); 123 | const listRef = React.createRef(); 124 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef }); 125 | const ulElement = wrapper.find('ul').instance(); 126 | // scroll to bottom 127 | listRef.current.scrollTo(99999); 128 | jest.runAllTimers(); 129 | expect(wrapper.find('ul').instance().scrollTop).toEqual(1900); 130 | 131 | act(() => { 132 | const event = new Event('MozMousePixelScroll'); 133 | event.detail = 6; 134 | event.preventDefault = preventDefault; 135 | ulElement.dispatchEvent(event); 136 | 137 | jest.runAllTimers(); 138 | }); 139 | 140 | expect(preventDefault).not.toHaveBeenCalled(); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /tests/scroll.test.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { act, createEvent, fireEvent, render } from '@testing-library/react'; 3 | import { mount } from 'enzyme'; 4 | import { _rs as onLibResize } from 'rc-resize-observer/lib/utils/observerUtil'; 5 | import { resetWarned } from 'rc-util/lib/warning'; 6 | import React from 'react'; 7 | import List from '../src'; 8 | import { spyElementPrototypes } from './utils/domHook'; 9 | 10 | function genData(count) { 11 | return new Array(count).fill(null).map((_, index) => ({ id: String(index) })); 12 | } 13 | 14 | // Mock ScrollBar 15 | jest.mock('../src/ScrollBar', () => { 16 | const OriScrollBar = jest.requireActual('../src/ScrollBar').default; 17 | const React = jest.requireActual('react'); 18 | return React.forwardRef((props, ref) => { 19 | const { scrollOffset } = props; 20 | 21 | return ( 22 |
    23 | 24 |
    25 | ); 26 | }); 27 | }); 28 | 29 | describe('List.Scroll', () => { 30 | let mockElement; 31 | let boundingRect = { 32 | top: 0, 33 | bottom: 0, 34 | width: 100, 35 | height: 100, 36 | }; 37 | 38 | beforeAll(() => { 39 | mockElement = spyElementPrototypes(HTMLElement, { 40 | offsetHeight: { 41 | get() { 42 | const height = this.getAttribute('data-height'); 43 | return Number(height || 20); 44 | }, 45 | }, 46 | clientHeight: { 47 | get() { 48 | const height = this.getAttribute('data-height'); 49 | return Number(height || 100); 50 | }, 51 | }, 52 | getBoundingClientRect: () => boundingRect, 53 | offsetParent: { 54 | get: () => document.body, 55 | }, 56 | }); 57 | }); 58 | 59 | afterAll(() => { 60 | mockElement.mockRestore(); 61 | }); 62 | 63 | beforeEach(() => { 64 | boundingRect = { 65 | top: 0, 66 | bottom: 0, 67 | width: 100, 68 | height: 100, 69 | }; 70 | jest.useFakeTimers(); 71 | }); 72 | 73 | afterEach(() => { 74 | jest.useRealTimers(); 75 | }); 76 | 77 | function genList(props, func = mount) { 78 | const mergedProps = { 79 | component: 'ul', 80 | itemKey: 'id', 81 | children: ({ id }) =>
  • {id}
  • , 82 | ...props, 83 | }; 84 | let node = ; 85 | 86 | if (props.ref) { 87 | node =
    {node}
    ; 88 | } 89 | 90 | return func(node); 91 | } 92 | 93 | it('scrollTo null will show the scrollbar', () => { 94 | jest.useFakeTimers(); 95 | const listRef = React.createRef(); 96 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef }); 97 | jest.runAllTimers(); 98 | 99 | listRef.current.scrollTo(null); 100 | expect(wrapper.find('.rc-virtual-list-scrollbar-thumb').props().style.display).not.toEqual( 101 | 'none', 102 | ); 103 | jest.useRealTimers(); 104 | }); 105 | 106 | describe('scrollTo number', () => { 107 | it('value scroll', () => { 108 | const listRef = React.createRef(); 109 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef }); 110 | listRef.current.scrollTo(903); 111 | jest.runAllTimers(); 112 | expect(wrapper.find('ul').instance().scrollTop).toEqual(903); 113 | 114 | wrapper.unmount(); 115 | }); 116 | }); 117 | 118 | describe('scroll to object', () => { 119 | function presetList() { 120 | const ref = React.createRef(); 121 | 122 | const result = genList({ itemHeight: 20, height: 100, data: genData(100), ref }, render); 123 | 124 | return { 125 | ...result, 126 | ref, 127 | scrollTo: (...args) => { 128 | ref.current.scrollTo(...args); 129 | 130 | act(() => { 131 | jest.runAllTimers(); 132 | }); 133 | }, 134 | }; 135 | } 136 | 137 | describe('index scroll', () => { 138 | it('work in range', () => { 139 | const { scrollTo, container } = presetList(); 140 | 141 | scrollTo({ index: 30, align: 'top' }); 142 | 143 | expect(container.querySelector('ul').scrollTop).toEqual(600); 144 | }); 145 | 146 | it('out of range should not crash', () => { 147 | expect(() => { 148 | const { scrollTo } = presetList(); 149 | scrollTo({ index: 99999999999, align: 'top' }); 150 | }).not.toThrow(); 151 | }); 152 | }); 153 | 154 | it('scroll top should not out of range', () => { 155 | const { scrollTo, container } = presetList(); 156 | scrollTo({ index: 0, align: 'bottom' }); 157 | jest.runAllTimers(); 158 | expect(container.querySelector('ul').scrollTop).toEqual(0); 159 | }); 160 | 161 | it('key scroll', () => { 162 | const { scrollTo, container } = presetList(); 163 | scrollTo({ key: '30', align: 'bottom' }); 164 | expect(container.querySelector('ul').scrollTop).toEqual(520); 165 | }); 166 | 167 | it('smart', () => { 168 | const { scrollTo, container } = presetList(); 169 | scrollTo(0); 170 | scrollTo({ index: 30 }); 171 | expect(container.querySelector('ul').scrollTop).toEqual(520); 172 | 173 | scrollTo(800); 174 | scrollTo({ index: 30 }); 175 | expect(container.querySelector('ul').scrollTop).toEqual(600); 176 | }); 177 | 178 | it('exceed should not warning', () => { 179 | resetWarned(); 180 | const errSpy = jest.spyOn(console, 'error'); 181 | 182 | const { scrollTo } = presetList(); 183 | scrollTo({ index: 9999999999, align: 'top' }); 184 | 185 | errSpy.mock.calls.forEach((msgs) => { 186 | expect(msgs[0]).not.toContain('max limitation'); 187 | }); 188 | 189 | errSpy.mockRestore(); 190 | }); 191 | }); 192 | 193 | it('inject wheel', () => { 194 | const preventDefault = jest.fn(); 195 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 196 | const ulElement = wrapper.find('ul').instance(); 197 | 198 | act(() => { 199 | const wheelEvent = new Event('wheel'); 200 | wheelEvent.deltaY = 3; 201 | wheelEvent.preventDefault = preventDefault; 202 | ulElement.dispatchEvent(wheelEvent); 203 | 204 | jest.runAllTimers(); 205 | }); 206 | 207 | expect(preventDefault).toHaveBeenCalled(); 208 | }); 209 | 210 | describe('scrollbar', () => { 211 | it('moving', () => { 212 | const listRef = React.createRef(); 213 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef }); 214 | 215 | // Mouse down 216 | wrapper.find('.rc-virtual-list-scrollbar-thumb').simulate('mousedown', { 217 | pageY: 0, 218 | }); 219 | 220 | // Mouse move 221 | act(() => { 222 | const mouseMoveEvent = new Event('mousemove'); 223 | mouseMoveEvent.pageY = 10; 224 | window.dispatchEvent(mouseMoveEvent); 225 | }); 226 | 227 | expect(wrapper.find('.rc-virtual-list-holder').props().style.pointerEvents).toEqual('none'); 228 | 229 | act(() => { 230 | jest.runAllTimers(); 231 | }); 232 | 233 | // Mouse up 234 | act(() => { 235 | const mouseUpEvent = new Event('mouseup'); 236 | window.dispatchEvent(mouseUpEvent); 237 | }); 238 | 239 | expect(wrapper.find('ul').instance().scrollTop > 10).toBeTruthy(); 240 | }); 241 | 242 | it('should show scrollbar when element has showScrollBar prop set to true', () => { 243 | jest.useFakeTimers(); 244 | const listRef = React.createRef(); 245 | const { container } = genList( 246 | { 247 | itemHeight: 20, 248 | height: 100, 249 | data: genData(100), 250 | ref: listRef, 251 | showScrollBar: true, 252 | }, 253 | render, 254 | ); 255 | act(() => { 256 | jest.runAllTimers(); 257 | }); 258 | const scrollbarElement = container.querySelector('.rc-virtual-list-scrollbar-visible'); 259 | expect(scrollbarElement).not.toBeNull(); 260 | }); 261 | describe('not show scrollbar when disabled virtual', () => { 262 | [ 263 | { name: '!virtual', props: { virtual: false } }, 264 | { 265 | name: '!height', 266 | props: { height: null }, 267 | }, 268 | { 269 | name: '!itemHeight', 270 | props: { itemHeight: null }, 271 | }, 272 | ].forEach(({ name, props }) => { 273 | it(name, () => { 274 | const wrapper = genList({ 275 | itemHeight: 20, 276 | height: 100, 277 | data: genData(5), 278 | ...props, 279 | }); 280 | expect(wrapper.find('.rc-virtual-list-scrollbar-thumb')).toHaveLength(0); 281 | }); 282 | }); 283 | }); 284 | }); 285 | 286 | it('no bubble', () => { 287 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 288 | 289 | // Mouse down 290 | const preventDefault = jest.fn(); 291 | const stopPropagation = jest.fn(); 292 | wrapper.find('.rc-virtual-list-scrollbar').simulate('mousedown', { 293 | preventDefault, 294 | stopPropagation, 295 | }); 296 | 297 | expect(preventDefault).toHaveBeenCalled(); 298 | expect(stopPropagation).toHaveBeenCalled(); 299 | }); 300 | 301 | it('onScroll should trigger on correct target', () => { 302 | // Save in tmp variable since React will clean up this 303 | let currentTarget; 304 | const onScroll = jest.fn((e) => { 305 | ({ currentTarget } = e); 306 | }); 307 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), onScroll }); 308 | wrapper.find('.rc-virtual-list-holder').simulate('scroll'); 309 | 310 | expect(currentTarget).toBe(wrapper.find('.rc-virtual-list-holder').hostNodes().instance()); 311 | }); 312 | 313 | describe('scroll should in range', () => { 314 | it('less than 0', () => { 315 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 316 | const ulElement = wrapper.find('ul').instance(); 317 | 318 | act(() => { 319 | const wheelEvent = new Event('wheel'); 320 | wheelEvent.deltaY = 9999999; 321 | ulElement.dispatchEvent(wheelEvent); 322 | 323 | jest.runAllTimers(); 324 | }); 325 | 326 | wrapper.setProps({ data: genData(1) }); 327 | act(() => { 328 | wrapper 329 | .find('.rc-virtual-list-holder') 330 | .props() 331 | .onScroll({ 332 | currentTarget: { 333 | scrollTop: 0, 334 | }, 335 | }); 336 | }); 337 | 338 | wrapper.setProps({ data: genData(100) }); 339 | 340 | expect(wrapper.find('ScrollBar').props().scrollOffset).toEqual(0); 341 | }); 342 | 343 | it('over max height', () => { 344 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 345 | const ulElement = wrapper.find('ul').instance(); 346 | 347 | act(() => { 348 | const wheelEvent = new Event('wheel'); 349 | wheelEvent.deltaY = 9999999; 350 | ulElement.dispatchEvent(wheelEvent); 351 | 352 | jest.runAllTimers(); 353 | }); 354 | 355 | wrapper.update(); 356 | 357 | expect(wrapper.find('ScrollBar').props().scrollOffset).toEqual(1900); 358 | }); 359 | 360 | it('dynamic large to small', () => { 361 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(1000) }); 362 | const ulElement = wrapper.find('ul').instance(); 363 | 364 | // To bottom 365 | act(() => { 366 | const wheelEvent = new Event('wheel'); 367 | wheelEvent.deltaY = 9999999; 368 | ulElement.dispatchEvent(wheelEvent); 369 | 370 | jest.runAllTimers(); 371 | }); 372 | 373 | // Cut data len 374 | wrapper.setProps({ 375 | data: genData(20), 376 | }); 377 | 378 | expect(wrapper.find('li').length).toBeLessThan(10); 379 | }); 380 | }); 381 | 382 | it('scrollbar should be left position with rtl', () => { 383 | jest.useFakeTimers(); 384 | const listRef = React.createRef(); 385 | const wrapper = genList({ 386 | itemHeight: 20, 387 | height: 100, 388 | data: genData(100), 389 | ref: listRef, 390 | direction: 'rtl', 391 | }); 392 | jest.runAllTimers(); 393 | 394 | listRef.current.scrollTo(null); 395 | expect(wrapper.find('.rc-virtual-list-scrollbar-thumb').props().style.display).not.toEqual( 396 | 'none', 397 | ); 398 | expect(wrapper.find('.rc-virtual-list-scrollbar').props().style.left).toEqual(0); 399 | jest.useRealTimers(); 400 | 401 | expect(wrapper.exists('.rc-virtual-list-rtl')).toBeTruthy(); 402 | }); 403 | 404 | it('wheel horizontal', () => { 405 | const { container } = genList( 406 | { 407 | itemHeight: 20, 408 | height: 100, 409 | data: genData(100), 410 | scrollWidth: 1000, 411 | }, 412 | render, 413 | ); 414 | 415 | const holder = container.querySelector('ul'); 416 | 417 | const event = createEvent.wheel(holder, { 418 | deltaX: -100, 419 | }); 420 | const spyPreventDefault = jest.spyOn(event, 'preventDefault'); 421 | 422 | fireEvent(holder, event); 423 | 424 | expect(spyPreventDefault).toHaveBeenCalled(); 425 | }); 426 | 427 | it('scroll to end should not has wrong extraRender', () => { 428 | const extraRender = jest.fn(({ start, end }) => { 429 | return null; 430 | }); 431 | 432 | jest.useFakeTimers(); 433 | const { container } = genList( 434 | { 435 | itemHeight: 20, 436 | height: 100, 437 | data: genData(100), 438 | extraRender, 439 | }, 440 | render, 441 | ); 442 | 443 | const holder = container.querySelector('ul'); 444 | 445 | const event = createEvent.wheel(holder, { 446 | deltaY: 99999999999999999999, 447 | }); 448 | fireEvent(holder, event); 449 | 450 | act(() => { 451 | jest.runAllTimers(); 452 | }); 453 | 454 | expect(extraRender).toHaveBeenCalledWith(expect.objectContaining({ end: 99 })); 455 | }); 456 | 457 | it('scrollbar styles should work', () => { 458 | const { container } = genList( 459 | { 460 | itemHeight: 20, 461 | height: 100, 462 | data: genData(100), 463 | scrollWidth: 1000, 464 | styles: { 465 | horizontalScrollBar: { background: 'red' }, 466 | horizontalScrollBarThumb: { background: 'green' }, 467 | verticalScrollBar: { background: 'orange' }, 468 | verticalScrollBarThumb: { background: 'blue' }, 469 | }, 470 | }, 471 | render, 472 | ); 473 | 474 | expect( 475 | container.querySelector('.rc-virtual-list-scrollbar-horizontal').style.background, 476 | ).toEqual('red'); 477 | expect( 478 | container.querySelector( 479 | '.rc-virtual-list-scrollbar-horizontal .rc-virtual-list-scrollbar-thumb', 480 | ).style.background, 481 | ).toEqual('green'); 482 | expect(container.querySelector('.rc-virtual-list-scrollbar-vertical').style.background).toEqual( 483 | 'orange', 484 | ); 485 | expect( 486 | container.querySelector( 487 | '.rc-virtual-list-scrollbar-vertical .rc-virtual-list-scrollbar-thumb', 488 | ).style.background, 489 | ).toEqual('blue'); 490 | }); 491 | 492 | it('scrollbar size should correct', async () => { 493 | boundingRect = { 494 | width: 0, 495 | height: 0, 496 | }; 497 | 498 | const { container } = genList( 499 | { 500 | itemHeight: 20, 501 | height: 100, 502 | data: genData(100), 503 | }, 504 | render, 505 | ); 506 | 507 | await act(async () => { 508 | onLibResize([ 509 | { 510 | target: container.querySelector('.rc-virtual-list-holder'), 511 | }, 512 | ]); 513 | 514 | await Promise.resolve(); 515 | }); 516 | 517 | expect(container.querySelector('.rc-virtual-list-scrollbar-thumb')).toHaveStyle({ 518 | height: `20px`, 519 | }); 520 | }); 521 | 522 | it('show scrollbar when actual height is larger than container height', async () => { 523 | jest.useRealTimers(); 524 | const { container } = genList( 525 | // set itemHeight * data.length < height, but sum of actual height > height 526 | { 527 | itemHeight: 8, 528 | height: 100, 529 | data: genData(10), 530 | }, 531 | render, 532 | ); 533 | 534 | await act(async () => { 535 | await new Promise((resolve) => { 536 | setTimeout(resolve, 10); 537 | }); 538 | }); 539 | 540 | expect(container.querySelector('.rc-virtual-list-scrollbar-thumb')).toBeVisible(); 541 | }); 542 | 543 | it('nest scroll', async () => { 544 | const { container } = genList( 545 | { 546 | itemHeight: 20, 547 | height: 100, 548 | data: genData(100), 549 | children: ({ id }) => 550 | id === '0' ? ( 551 |
  • 552 | 553 | {({ id }) =>
  • {id}
  • } 554 |
    555 | 556 | ) : ( 557 |
  • 558 | ), 559 | }, 560 | render, 561 | ); 562 | 563 | fireEvent.wheel(container.querySelector('ul ul li'), { 564 | deltaY: 10, 565 | }); 566 | 567 | await act(async () => { 568 | jest.advanceTimersByTime(1000000); 569 | await Promise.resolve(); 570 | }); 571 | 572 | // inner 573 | expect(container.querySelectorAll('[data-dev-offset]')[0]).toHaveAttribute( 574 | 'data-dev-offset', 575 | '10', 576 | ); 577 | 578 | // outer 579 | expect(container.querySelectorAll('[data-dev-offset]')[1]).toHaveAttribute( 580 | 'data-dev-offset', 581 | '0', 582 | ); 583 | }); 584 | 585 | describe('mouse down drag', () => { 586 | function dragDown(container, mouseY, button = 0) { 587 | fireEvent.mouseDown(container.querySelector('li'), { 588 | button, 589 | }); 590 | 591 | let moveEvent = createEvent.mouseMove(container.querySelector('li')); 592 | moveEvent.pageY = mouseY; 593 | fireEvent(container.querySelector('li'), moveEvent); 594 | 595 | act(() => { 596 | jest.advanceTimersByTime(100); 597 | }); 598 | 599 | fireEvent.mouseUp(container.querySelector('li')); 600 | } 601 | 602 | function getScrollTop(container) { 603 | const innerEle = container.querySelector('.rc-virtual-list-holder-inner'); 604 | const { transform } = innerEle.style; 605 | return Number(transform.match(/\d+/)[0]); 606 | } 607 | 608 | it('can move', () => { 609 | const onScroll = jest.fn(); 610 | const { container } = render( 611 | 619 | {({ id }) =>
  • {id}
  • } 620 | , 621 | ); 622 | 623 | // Drag down 624 | dragDown(container, 100); 625 | expect(getScrollTop(container)).toBeGreaterThan(0); 626 | 627 | // Drag up 628 | dragDown(container, -100); 629 | expect(getScrollTop(container)).toBe(0); 630 | }); 631 | 632 | it('right click should not move', () => { 633 | const onScroll = jest.fn(); 634 | const { container } = render( 635 | 643 | {({ id }) =>
  • {id}
  • } 644 |
    , 645 | ); 646 | 647 | // Drag down 648 | dragDown(container, 100, 2); 649 | expect(getScrollTop(container)).toBe(0); 650 | }); 651 | 652 | it('can not move when item add draggable', () => { 653 | const onScroll = jest.fn(); 654 | const { container } = render( 655 | 663 | {({ id }) =>
  • {id}
  • } 664 |
    , 665 | ); 666 | 667 | // Initial scroll should be 0 668 | expect(getScrollTop(container)).toEqual(0); 669 | // Simulate drag action 670 | dragDown(container, 100); 671 | // Assert that scroll did not change after drag 672 | expect(getScrollTop(container)).toEqual(0); 673 | }); 674 | }); 675 | 676 | it('not scroll jump for item height change', async () => { 677 | jest.useFakeTimers(); 678 | 679 | const onScroll = jest.fn(); 680 | 681 | const listRef = React.createRef(); 682 | const { container } = genList( 683 | { 684 | itemHeight: 10, 685 | height: 100, 686 | data: genData(100), 687 | ref: listRef, 688 | children: ({ id }) =>
  • {id}
  • , 689 | onScroll, 690 | }, 691 | render, 692 | ); 693 | 694 | // first render refresh 695 | await act(async () => { 696 | onLibResize([ 697 | { 698 | target: container.querySelector('.rc-virtual-list-holder-inner'), 699 | }, 700 | ]); 701 | 702 | await Promise.resolve(); 703 | }); 704 | 705 | await act(async () => { 706 | jest.advanceTimersByTime(1000); 707 | await Promise.resolve(); 708 | }); 709 | 710 | container.querySelector('li[data-id="0"]').setAttribute('data-height', '30'); 711 | 712 | // Force change first row height 713 | await act(async () => { 714 | boundingRect.height = 110; 715 | 716 | onLibResize([ 717 | { 718 | target: container.querySelector('.rc-virtual-list-holder-inner'), 719 | }, 720 | ]); 721 | 722 | await Promise.resolve(); 723 | }); 724 | 725 | await act(async () => { 726 | jest.advanceTimersByTime(1000); 727 | await Promise.resolve(); 728 | }); 729 | 730 | expect(onScroll).not.toHaveBeenCalled(); 731 | 732 | jest.useRealTimers(); 733 | }); 734 | }); 735 | -------------------------------------------------------------------------------- /tests/scrollWidth.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { act, fireEvent, render } from '@testing-library/react'; 3 | import { _rs as onLibResize } from 'rc-resize-observer/lib/utils/observerUtil'; 4 | import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; 5 | import React from 'react'; 6 | import type { ListRef } from '../src'; 7 | import List, { type ListProps } from '../src'; 8 | 9 | const ITEM_HEIGHT = 20; 10 | 11 | function genData(count) { 12 | return new Array(count).fill(null).map((_, index) => ({ id: String(index) })); 13 | } 14 | 15 | describe('List.scrollWidth', () => { 16 | let mockElement; 17 | let mockMouseEvent; 18 | let pageX: number; 19 | 20 | const holderHeight = 100; 21 | let holderWidth = 100; 22 | 23 | beforeAll(() => { 24 | mockElement = spyElementPrototypes(HTMLElement, { 25 | offsetHeight: { 26 | get() { 27 | if (this.classList.contains('rc-virtual-list-holder')) { 28 | return holderHeight; 29 | } 30 | return ITEM_HEIGHT; 31 | }, 32 | }, 33 | offsetWidth: { 34 | get() { 35 | return holderWidth; 36 | }, 37 | }, 38 | clientHeight: { 39 | get() { 40 | return holderHeight; 41 | }, 42 | }, 43 | getBoundingClientRect() { 44 | return { 45 | width: holderWidth, 46 | height: holderHeight, 47 | }; 48 | }, 49 | }); 50 | 51 | mockMouseEvent = spyElementPrototypes(MouseEvent, { 52 | pageX: { 53 | get: () => pageX, 54 | }, 55 | }); 56 | }); 57 | 58 | afterAll(() => { 59 | mockElement.mockRestore(); 60 | mockMouseEvent.mockRestore(); 61 | }); 62 | 63 | beforeEach(() => { 64 | holderWidth = 100; 65 | jest.useFakeTimers(); 66 | }); 67 | 68 | afterEach(() => { 69 | jest.clearAllTimers(); 70 | jest.useRealTimers(); 71 | }); 72 | 73 | async function genList(props: Partial> & { ref?: any }) { 74 | const ret = render( 75 | 76 | {({ id }) =>
  • {id}
  • } 77 |
    , 78 | ); 79 | 80 | await act(async () => { 81 | onLibResize([ 82 | { 83 | target: ret.container.querySelector('.rc-virtual-list-holder')!, 84 | } as ResizeObserverEntry, 85 | ]); 86 | 87 | await Promise.resolve(); 88 | }); 89 | 90 | return ret; 91 | } 92 | 93 | it('work', async () => { 94 | const { container } = await genList({ 95 | itemHeight: ITEM_HEIGHT, 96 | height: 100, 97 | data: genData(100), 98 | scrollWidth: 1000, 99 | }); 100 | 101 | expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeTruthy(); 102 | }); 103 | 104 | describe('trigger offset', () => { 105 | it('drag scrollbar', async () => { 106 | const onVirtualScroll = jest.fn(); 107 | const listRef = React.createRef(); 108 | 109 | const props = { 110 | itemHeight: ITEM_HEIGHT, 111 | height: 100, 112 | data: genData(100), 113 | scrollWidth: 1000, 114 | onVirtualScroll, 115 | ref: listRef, 116 | }; 117 | 118 | const { container, rerender } = await genList(props); 119 | 120 | await act(async () => { 121 | onLibResize([ 122 | { 123 | target: container.querySelector('.rc-virtual-list-holder')!, 124 | } as ResizeObserverEntry, 125 | ]); 126 | 127 | await Promise.resolve(); 128 | }); 129 | 130 | // Drag 131 | const thumb = container.querySelector( 132 | '.rc-virtual-list-scrollbar-horizontal .rc-virtual-list-scrollbar-thumb', 133 | )!; 134 | 135 | pageX = 10; 136 | fireEvent.mouseDown(thumb); 137 | 138 | pageX = 100000; 139 | fireEvent.mouseMove(window); 140 | 141 | act(() => { 142 | jest.runAllTimers(); 143 | }); 144 | 145 | fireEvent.mouseUp(window); 146 | 147 | expect(thumb).toHaveStyle({ 148 | left: '80px', 149 | width: '20px', 150 | }); 151 | 152 | expect(onVirtualScroll).toHaveBeenCalledWith({ x: 900, y: 0 }); 153 | expect(listRef.current.getScrollInfo()).toEqual({ x: 900, y: 0 }); 154 | 155 | act(() => { 156 | rerender( 157 | 158 | {({ id }) =>
  • {id}
  • } 159 |
    , 160 | ); 161 | }); 162 | expect(onVirtualScroll).toHaveBeenCalledWith({ x: 500, y: 0 }); 163 | expect(listRef.current.getScrollInfo()).toEqual({ x: 500, y: 0 }); 164 | }); 165 | 166 | it('wheel', async () => { 167 | const onVirtualScroll = jest.fn(); 168 | 169 | const { container } = await genList({ 170 | itemHeight: ITEM_HEIGHT, 171 | height: 100, 172 | data: genData(100), 173 | scrollWidth: 1000, 174 | onVirtualScroll, 175 | }); 176 | 177 | // Wheel 178 | fireEvent.wheel(container.querySelector('.rc-virtual-list-holder')!, { 179 | deltaX: 123, 180 | }); 181 | expect(onVirtualScroll).toHaveBeenCalledWith({ x: 123, y: 0 }); 182 | }); 183 | 184 | it('trigger event when less count', async () => { 185 | const onVirtualScroll = jest.fn(); 186 | 187 | const { container } = await genList({ 188 | itemHeight: ITEM_HEIGHT, 189 | height: 100, 190 | data: genData(1), 191 | scrollWidth: 1000, 192 | onVirtualScroll, 193 | }); 194 | 195 | // Wheel 196 | fireEvent.wheel(container.querySelector('.rc-virtual-list-holder')!, { 197 | deltaX: 123, 198 | }); 199 | expect(onVirtualScroll).toHaveBeenCalledWith({ x: 123, y: 0 }); 200 | }); 201 | 202 | it('shift wheel', async () => { 203 | const onVirtualScroll = jest.fn(); 204 | 205 | const { container } = await genList({ 206 | itemHeight: ITEM_HEIGHT, 207 | height: 100, 208 | data: genData(100), 209 | scrollWidth: 1000, 210 | onVirtualScroll, 211 | }); 212 | 213 | // Wheel 214 | fireEvent.wheel(container.querySelector('.rc-virtual-list-holder')!, { 215 | deltaY: 123, 216 | shiftKey: true, 217 | }); 218 | expect(onVirtualScroll).toHaveBeenCalledWith({ x: 123, y: 0 }); 219 | }); 220 | }); 221 | 222 | it('ref scrollTo', async () => { 223 | const listRef = React.createRef(); 224 | 225 | await genList({ 226 | itemHeight: ITEM_HEIGHT, 227 | height: 100, 228 | data: genData(100), 229 | scrollWidth: 1000, 230 | ref: listRef, 231 | }); 232 | 233 | listRef.current.scrollTo({ left: 135 }); 234 | expect(listRef.current.getScrollInfo()).toEqual({ x: 135, y: 0 }); 235 | 236 | listRef.current.scrollTo({ left: -99 }); 237 | expect(listRef.current.getScrollInfo()).toEqual({ x: 0, y: 0 }); 238 | }); 239 | 240 | it('support extraRender', async () => { 241 | const { container } = await genList({ 242 | itemHeight: ITEM_HEIGHT, 243 | height: 100, 244 | data: genData(100), 245 | scrollWidth: 1000, 246 | extraRender: ({ getSize }) => { 247 | const size = getSize('1', '3'); 248 | return ( 249 |
    250 | {size.top}/{size.bottom} 251 |
    252 | ); 253 | }, 254 | }); 255 | 256 | expect(container.querySelector('.rc-virtual-list-holder-inner .bamboo')).toBeTruthy(); 257 | expect(container.querySelector('.bamboo').textContent).toEqual( 258 | `${ITEM_HEIGHT}/${4 * ITEM_HEIGHT}`, 259 | ); 260 | }); 261 | 262 | it('resize should back of scrollLeft', async () => { 263 | const { container } = await genList({ 264 | itemHeight: ITEM_HEIGHT, 265 | height: 100, 266 | data: genData(100), 267 | scrollWidth: 1000, 268 | }); 269 | 270 | // Wheel 271 | fireEvent.wheel(container.querySelector('.rc-virtual-list-holder')!, { 272 | deltaX: 9999999, 273 | }); 274 | 275 | holderWidth = 200; 276 | 277 | await act(async () => { 278 | onLibResize([ 279 | { 280 | target: container.querySelector('.rc-virtual-list-holder')!, 281 | } as ResizeObserverEntry, 282 | ]); 283 | 284 | await Promise.resolve(); 285 | }); 286 | 287 | expect(container.querySelector('.rc-virtual-list-holder-inner')).toHaveStyle({ 288 | marginLeft: '-800px', 289 | }); 290 | }); 291 | 292 | it('touch horizontal', async () => { 293 | const { container } = await genList({ 294 | itemHeight: ITEM_HEIGHT, 295 | height: 100, 296 | data: genData(100), 297 | scrollWidth: 1000, 298 | }); 299 | 300 | fireEvent.touchStart(container.querySelector('.rc-virtual-list-holder')!, { 301 | touches: [{ pageX: 100, pageY: 0 }], 302 | }); 303 | 304 | fireEvent.touchMove(container.querySelector('.rc-virtual-list-holder')!, { 305 | touches: [{ pageX: 0, pageY: 0 }], 306 | }); 307 | 308 | fireEvent.touchEnd(container.querySelector('.rc-virtual-list-holder')!, { 309 | touches: [{ pageX: 0, pageY: 0 }], 310 | }); 311 | 312 | act(() => { 313 | jest.runAllTimers(); 314 | }); 315 | 316 | expect(container.querySelector('.rc-virtual-list-holder-inner')).toHaveStyle({ 317 | marginLeft: '-900px', 318 | }); 319 | }); 320 | }); 321 | -------------------------------------------------------------------------------- /tests/touch.test.js: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react'; 2 | import { mount } from 'enzyme'; 3 | import React from 'react'; 4 | import List from '../src'; 5 | import { spyElementPrototypes } from './utils/domHook'; 6 | 7 | // Mock ScrollBar 8 | jest.mock('../src/ScrollBar', () => { 9 | const OriScrollBar = jest.requireActual('../src/ScrollBar').default; 10 | const React = jest.requireActual('react'); 11 | return React.forwardRef((props, ref) => { 12 | const { scrollOffset } = props; 13 | 14 | return ( 15 |
    16 | 17 |
    18 | ); 19 | }); 20 | }); 21 | 22 | function genData(count) { 23 | return new Array(count).fill(null).map((_, index) => ({ id: String(index) })); 24 | } 25 | 26 | describe('List.Touch', () => { 27 | let mockElement; 28 | 29 | beforeAll(() => { 30 | mockElement = spyElementPrototypes(HTMLElement, { 31 | offsetHeight: { 32 | get: () => 20, 33 | }, 34 | clientHeight: { 35 | get: () => 100, 36 | }, 37 | }); 38 | }); 39 | 40 | afterAll(() => { 41 | mockElement.mockRestore(); 42 | }); 43 | 44 | beforeEach(() => { 45 | jest.useFakeTimers(); 46 | }); 47 | 48 | afterEach(() => { 49 | jest.useRealTimers(); 50 | }); 51 | 52 | function genList(props) { 53 | let node = ( 54 | 55 | {({ id }) =>
  • {id}
  • } 56 |
    57 | ); 58 | 59 | if (props.ref) { 60 | node =
    {node}
    ; 61 | } 62 | 63 | return mount(node); 64 | } 65 | 66 | describe('touch content', () => { 67 | it('touch scroll should work', () => { 68 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 69 | 70 | function getElement() { 71 | return wrapper.find('.rc-virtual-list-holder').instance(); 72 | } 73 | 74 | // start 75 | const touchEvent = new Event('touchstart'); 76 | touchEvent.touches = [{ pageY: 100 }]; 77 | getElement().dispatchEvent(touchEvent); 78 | 79 | // move 80 | const moveEvent = new Event('touchmove'); 81 | moveEvent.touches = [{ pageY: 90 }]; 82 | getElement().dispatchEvent(moveEvent); 83 | 84 | // end 85 | const endEvent = new Event('touchend'); 86 | getElement().dispatchEvent(endEvent); 87 | 88 | // smooth 89 | jest.runAllTimers(); 90 | expect(wrapper.find('ul').instance().scrollTop > 10).toBeTruthy(); 91 | 92 | wrapper.unmount(); 93 | }); 94 | 95 | it('not call when not scroll-able', () => { 96 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 97 | 98 | function getElement() { 99 | return wrapper.find('.rc-virtual-list-holder').instance(); 100 | } 101 | 102 | // start 103 | const touchEvent = new Event('touchstart'); 104 | touchEvent.touches = [{ pageY: 500 }]; 105 | getElement().dispatchEvent(touchEvent); 106 | 107 | // move 108 | const preventDefault = jest.fn(); 109 | const moveEvent = new Event('touchmove'); 110 | moveEvent.touches = [{ pageY: 0 }]; 111 | moveEvent.preventDefault = preventDefault; 112 | getElement().dispatchEvent(moveEvent); 113 | 114 | // Call preventDefault 115 | expect(preventDefault).toHaveBeenCalled(); 116 | 117 | // ======= Not call since scroll to the bottom ======= 118 | jest.runAllTimers(); 119 | preventDefault.mockReset(); 120 | 121 | // start 122 | const touchEvent2 = new Event('touchstart'); 123 | touchEvent2.touches = [{ pageY: 500 }]; 124 | getElement().dispatchEvent(touchEvent2); 125 | 126 | // move 127 | const moveEvent2 = new Event('touchmove'); 128 | moveEvent2.touches = [{ pageY: 0 }]; 129 | moveEvent2.preventDefault = preventDefault; 130 | getElement().dispatchEvent(moveEvent2); 131 | 132 | expect(preventDefault).not.toHaveBeenCalled(); 133 | }); 134 | }); 135 | 136 | it('should container preventDefault', () => { 137 | const preventDefault = jest.fn(); 138 | const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); 139 | 140 | const touchEvent = new Event('touchstart'); 141 | touchEvent.preventDefault = preventDefault; 142 | wrapper.find('.rc-virtual-list-scrollbar').instance().dispatchEvent(touchEvent); 143 | 144 | expect(preventDefault).toHaveBeenCalled(); 145 | }); 146 | 147 | it('nest touch', async () => { 148 | const { container } = render( 149 | 150 | {({ id }) => 151 | id === '0' ? ( 152 |
  • 153 | 154 | {({ id }) =>
  • {id}
  • } 155 |
    156 | 157 | ) : ( 158 |
  • 159 | ) 160 | } 161 | , 162 | ); 163 | 164 | const targetLi = container.querySelector('ul ul li'); 165 | 166 | fireEvent.touchStart(targetLi, { 167 | touches: [{ pageY: 0 }], 168 | }); 169 | 170 | fireEvent.touchMove(targetLi, { 171 | touches: [{ pageY: -1 }], 172 | }); 173 | 174 | await act(async () => { 175 | jest.advanceTimersByTime(1000000); 176 | await Promise.resolve(); 177 | }); 178 | 179 | // inner not to be 0 180 | expect(container.querySelectorAll('[data-dev-offset]')[0]).toHaveAttribute('data-dev-offset'); 181 | expect(container.querySelectorAll('[data-dev-offset]')[0]).not.toHaveAttribute( 182 | 'data-dev-offset', 183 | '0', 184 | ); 185 | 186 | // outer 187 | expect(container.querySelectorAll('[data-dev-offset]')[1]).toHaveAttribute( 188 | 'data-dev-offset', 189 | '0', 190 | ); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /tests/util.test.js: -------------------------------------------------------------------------------- 1 | import { getIndexByStartLoc, findListDiffIndex } from '../src/utils/algorithmUtil'; 2 | 3 | describe('Util', () => { 4 | describe('Algorithm', () => { 5 | describe('getIndexByStartLoc', () => { 6 | function test(name, min, max, start, expectList) { 7 | it(name, () => { 8 | const len = max - min + 1; 9 | const renderList = new Array(len) 10 | .fill(null) 11 | .map((_, index) => getIndexByStartLoc(min, max, start, index)); 12 | 13 | expect(renderList).toEqual(expectList); 14 | }); 15 | } 16 | 17 | // Balance 18 | test('balance - basic', 0, 2, 1, [1, 2, 0]); 19 | test('balance - moving', 3, 13, 8, [8, 9, 7, 10, 6, 11, 5, 12, 4, 13, 3]); 20 | 21 | // After less 22 | test('after less', 3, 9, 7, [7, 8, 6, 9, 5, 4, 3]); 23 | 24 | // Before less 25 | test('before less', 1, 9, 3, [3, 4, 2, 5, 1, 6, 7, 8, 9]); 26 | }); 27 | 28 | describe('findListDiff', () => { 29 | describe('remove', () => { 30 | function test(name, length, diff) { 31 | it(name, () => { 32 | const originList = new Array(length).fill(null).map((_, index) => index); 33 | const targetList = originList.slice(); 34 | targetList.splice(diff, 1); 35 | 36 | expect(findListDiffIndex(originList, targetList, num => num)).toEqual({ 37 | index: diff, 38 | multiple: false, 39 | }); 40 | }); 41 | } 42 | 43 | for (let i = 0; i < 100; i += 1) { 44 | test(`diff index: ${i}`, 100, i); 45 | } 46 | }); 47 | 48 | describe('add', () => { 49 | function test(name, length, diff) { 50 | it(name, () => { 51 | const originList = new Array(length).fill(null).map((_, index) => index); 52 | const targetList = originList.slice(); 53 | targetList.splice(diff, 0, 'NEW_ITEM'); 54 | 55 | expect(findListDiffIndex(originList, targetList, num => num)).toEqual({ 56 | index: diff, 57 | multiple: false, 58 | }); 59 | }); 60 | } 61 | 62 | for (let i = 0; i < 100; i += 1) { 63 | test(`diff index: ${i}`, 100, i); 64 | } 65 | }); 66 | 67 | it('both empty', () => { 68 | expect(findListDiffIndex([], [], num => num)).toEqual(null); 69 | }); 70 | 71 | it('same list', () => { 72 | const list = [1, 2, 3, 4]; 73 | expect(findListDiffIndex(list, list, num => num)).toEqual(null); 74 | }); 75 | 76 | it('small list', () => { 77 | expect(findListDiffIndex([0], [], num => num)).toEqual({ 78 | index: 0, 79 | multiple: false, 80 | }); 81 | expect(findListDiffIndex([0, 1], [0], num => num)).toEqual({ 82 | index: 1, 83 | multiple: false, 84 | }); 85 | expect(findListDiffIndex([0, 1, 2], [0], num => num)).toEqual({ 86 | index: 1, 87 | multiple: true, 88 | }); 89 | expect(findListDiffIndex([], [0], num => num)).toEqual({ 90 | index: 0, 91 | multiple: false, 92 | }); 93 | expect(findListDiffIndex([0], [0, 1], num => num)).toEqual({ 94 | index: 1, 95 | multiple: false, 96 | }); 97 | }); 98 | 99 | it('diff only 1', () => { 100 | const indexArray = [0, 1, 2]; 101 | expect(findListDiffIndex(indexArray, [], num => num)).toEqual({ 102 | index: 0, 103 | multiple: true, 104 | }); 105 | expect(findListDiffIndex(indexArray, [1, 2], num => num)).toEqual({ 106 | index: 0, 107 | multiple: false, 108 | }); 109 | expect(findListDiffIndex(indexArray, [0, 2], num => num)).toEqual({ 110 | index: 1, 111 | multiple: false, 112 | }); 113 | expect(findListDiffIndex(indexArray, [0, 1], num => num)).toEqual({ 114 | index: 2, 115 | multiple: false, 116 | }); 117 | expect(findListDiffIndex(indexArray, [0], num => num)).toEqual({ 118 | index: 1, 119 | multiple: true, 120 | }); 121 | expect(findListDiffIndex(indexArray, [1], num => num)).toEqual({ 122 | index: 0, 123 | multiple: true, 124 | }); 125 | expect(findListDiffIndex([0, 1, 2], [2], num => num)).toEqual({ 126 | index: 0, 127 | multiple: true, 128 | }); 129 | }); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/utils/domHook.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | const NO_EXIST = { __NOT_EXIST: true }; 3 | 4 | export function spyElementPrototypes(Element, properties) { 5 | const propNames = Object.keys(properties); 6 | const originDescriptors = {}; 7 | 8 | propNames.forEach(propName => { 9 | const originDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, propName); 10 | originDescriptors[propName] = originDescriptor || NO_EXIST; 11 | 12 | const spyProp = properties[propName]; 13 | 14 | if (typeof spyProp === 'function') { 15 | // If is a function 16 | Element.prototype[propName] = function spyFunc(...args) { 17 | return spyProp.call(this, originDescriptor, ...args); 18 | }; 19 | } else { 20 | // Otherwise tread as a property 21 | Object.defineProperty(Element.prototype, propName, { 22 | ...spyProp, 23 | set(value) { 24 | if (spyProp.set) { 25 | return spyProp.set.call(this, originDescriptor, value); 26 | } 27 | return originDescriptor.set(value); 28 | }, 29 | get() { 30 | if (spyProp.get) { 31 | return spyProp.get.call(this, originDescriptor); 32 | } 33 | return originDescriptor.get(); 34 | }, 35 | configurable: true, 36 | }); 37 | } 38 | }); 39 | 40 | return { 41 | mockRestore() { 42 | propNames.forEach(propName => { 43 | const originDescriptor = originDescriptors[propName]; 44 | if (originDescriptor === NO_EXIST) { 45 | delete Element.prototype[propName]; 46 | } else if (typeof originDescriptor === 'function') { 47 | Element.prototype[propName] = originDescriptor; 48 | } else { 49 | Object.defineProperty(Element.prototype, propName, originDescriptor); 50 | } 51 | }); 52 | }, 53 | }; 54 | } 55 | 56 | export function spyElementPrototype(Element, propName, property) { 57 | return spyElementPrototypes(Element, { 58 | [propName]: property, 59 | }); 60 | } 61 | /* eslint-enable no-param-reassign */ 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "baseUrl": "./", 6 | "jsx": "react", 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "paths": { 11 | "@/*": [ 12 | "src/*" 13 | ], 14 | "@@/*": [ 15 | "src/.umi/*" 16 | ], 17 | "rc-virtual-list": [ 18 | "src/index.ts" 19 | ] 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /update-demo.js: -------------------------------------------------------------------------------- 1 | /* 2 | 用于 dumi 改造使用, 3 | 可用于将 examples 的文件批量修改为 demo 引入形式, 4 | 其他项目根据具体情况使用。 5 | */ 6 | 7 | const fs = require('fs'); 8 | const glob = require('glob'); 9 | 10 | const paths = glob.sync('./examples/*.tsx'); 11 | 12 | paths.forEach(path => { 13 | const name = path.split('/').pop().split('.')[0]; 14 | fs.writeFile( 15 | `./docs/demo/${name}.md`, 16 | `## ${name} 17 | 18 | 19 | `, 20 | 'utf8', 21 | function(error) { 22 | if(error){ 23 | console.log(error); 24 | return false; 25 | } 26 | console.log(`${name} 更新成功~`); 27 | } 28 | ) 29 | }); 30 | --------------------------------------------------------------------------------