diff --git a/README.md b/README.md index 0dcb8f9a..aa2de98b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ open http://localhost:9001/ ```js import List from 'rc-virtual-list'; - + {index =>
{index}
}
; ``` @@ -51,15 +51,25 @@ import List from 'rc-virtual-list'; ## List -| Prop | Description | Type | Default | -| ---------- | ------------------------------------------------------- | ------------------------------------ | ------- | -| children | Render props of item | (item, index, props) => ReactElement | - | -| component | Customize List dom element | string \| Component | div | -| data | Data list | Array | - | -| disabled | Disable scroll check. Usually used on animation control | boolean | false | -| height | List height | number | - | -| itemHeight | Item minium height | number | - | -| itemKey | Match key with item | string | - | - -`children` provides additional `props` argument to support IE 11 scroll shaking. -It will set `style` to `visibility: hidden` when measuring. You can ignore this if no requirement on IE. +| Prop | Description | Type | Default | +| --------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------- | +| prefixCls | prefix of list element and children class | string | rc-virtual-list | +| className | wrapper className of list element | string | - | +| style | wrapper style of list element | React.CSSProperties | - | +| children | Render props of item | (item, index, props) => ReactElement | - | +| component | Customize List dom element | string \| Component | div | +| data | Data list | Array | - | +| disabled | Disable scroll check. Usually used on animation control | boolean | false | +| containerSize | List width or height. If string is passed, it's must be a valid size or it's parent has a valid size | number \| string | - | +| isStaticItem | Whether width or height of item is fixed. The `itemSize` properity is required when It set to true | boolean | false | +| itemSize | Item minium width or height. It's required when item is fixed size or you want to enable virtual mode | number | - | +| itemKey | Match key with item | string \| (\(item: T) => React.Key) | - | +| direction | list direction: IDirection.Horizontal or IDirection.Vertical | IDirection | IDirection.Vertical | +| isFullSize | always enable container size even if children's size not reach | boolean | true | +| isEnableVirtual | enable or disable virtual mode. The properity `containerSize` and `itemSize` is required to enable virtual list mode | boolean | false | +| onScroll | callback when list is scroll | React.UIEventHandler | - | +| onVisibleChange | trigger when render list item changed | (visibleList: T[], fullList: T[]) => void | - | +| innerProps | Inject to inner container props. Only use when you need pass aria related data | React.CSSProperties | - | + +> `children` provides additional `props` argument to support IE 11 scroll shaking. +> It will set `style` to `visibility: hidden` when measuring. You can ignore this if no requirement on IE. \ No newline at end of file diff --git a/docs/demo/no-virtual.md b/docs/demo/no-virtual.md deleted file mode 100644 index d139d2aa..00000000 --- a/docs/demo/no-virtual.md +++ /dev/null @@ -1,3 +0,0 @@ -## no-virtual - - diff --git a/docs/demo/noVirtual.md b/docs/demo/noVirtual.md new file mode 100644 index 00000000..0917f458 --- /dev/null +++ b/docs/demo/noVirtual.md @@ -0,0 +1,3 @@ +## no virtual + + diff --git a/docs/demo/staticItem.md b/docs/demo/staticItem.md new file mode 100644 index 00000000..ce4b362a --- /dev/null +++ b/docs/demo/staticItem.md @@ -0,0 +1,3 @@ +## static item + + diff --git a/docs/demo/stringContainerSize.md b/docs/demo/stringContainerSize.md new file mode 100644 index 00000000..4d44b968 --- /dev/null +++ b/docs/demo/stringContainerSize.md @@ -0,0 +1,3 @@ +## string type containerSize + + diff --git a/examples/Item/index.less b/examples/Item/index.less new file mode 100644 index 00000000..89eca326 --- /dev/null +++ b/examples/Item/index.less @@ -0,0 +1,18 @@ +.fixed-item { + display: inline-block; + box-sizing: border-box; + height: 32px; + padding: 0 16px; + line-height: 30px; + border-bottom: 1px solid gray; + + &.fixed-item-horizontal { + width: 150px; + height: 120px; + border-right: 1px solid gray; + } +} + +.item-horizontal { + height: 120px; +} diff --git a/examples/Item/index.tsx b/examples/Item/index.tsx new file mode 100644 index 00000000..4b1076d9 --- /dev/null +++ b/examples/Item/index.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from 'react'; +import { useIsHorizontalMode } from '../../src/hooks'; +import type { CSSProperties, ForwardRefRenderFunction } from 'react'; +import type { IDirection } from '../../src/types'; +import './index.less'; + +export interface IItem extends Omit { + id: number; + size: number; + direction?: IDirection; + style?: CSSProperties; +} + +const Item: ForwardRefRenderFunction = ( + { id, direction, size, style = {}, ...restProps }, + ref, +) => { + const isHorizontalMode = useIsHorizontalMode(direction); + return ( + + {id} + + ); +}; + +export const ForwardMyItem = forwardRef(Item); + +export default Item; diff --git a/examples/animate.less b/examples/animate.less index 753fff92..23dd742c 100644 --- a/examples/animate.less +++ b/examples/animate.less @@ -3,29 +3,39 @@ } .item { + position: relative; display: inline-block; box-sizing: border-box; margin: 0; padding: 0 16px; overflow: hidden; line-height: 31px; - position: relative; &:hover { background: rgba(255, 0, 0, 0.1); } &::after { - content: ''; - border-bottom: 1px solid gray; position: absolute; + right: 0; bottom: 0; left: 0; - right: 0; + border-bottom: 1px solid gray; + content: ''; } button { - vertical-align: text-top; margin-right: 8px; + vertical-align: text-top; + } + + &.item-horizontal { + flex-shrink: 0; + width: 150px; + border-right: 1px solid gray; + + > div { + width: 100%; + } } } diff --git a/examples/animate.tsx b/examples/animate.tsx index daa632f3..a684dcb3 100644 --- a/examples/animate.tsx +++ b/examples/animate.tsx @@ -1,11 +1,10 @@ -/* eslint-disable arrow-body-style */ - -import * as React from 'react'; -// @ts-ignore +import React, { forwardRef, StrictMode, useRef, useState } from 'react'; import CSSMotion from 'rc-animate/lib/CSSMotion'; -import classNames from 'classnames'; -import List, { ListRef } from '../src/List'; +import List from '../src/List'; +import { useIsHorizontalMode } from '../src/hooks'; import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; +import { IDirection} from '../src/types'; +import type { IListRef } from '../src/types'; import './animate.less'; let uuid = 0; @@ -29,6 +28,7 @@ interface Item { } interface MyItemProps extends Item { + direction?: IDirection; visible: boolean; motionAppear: boolean; onClose: (id: string) => void; @@ -48,6 +48,7 @@ const MyItem: React.ForwardRefRenderFunction = ( { id, uuid: itemUuid, + direction = IDirection.Vertical, visible, onClose, onLeave, @@ -58,7 +59,8 @@ const MyItem: React.ForwardRefRenderFunction = ( }, ref, ) => { - const motionRef = React.useRef(false); + const motionRef = useRef(false); + const isHorizontalMode = useIsHorizontalMode(direction); useLayoutEffect(() => { return () => { if (motionRef.current) { @@ -89,7 +91,7 @@ const MyItem: React.ForwardRefRenderFunction = ( return (
@@ -127,15 +129,15 @@ const MyItem: React.ForwardRefRenderFunction = ( ); }; -const ForwardMyItem = React.forwardRef(MyItem); +const ForwardMyItem = forwardRef(MyItem); const Demo = () => { - const [data, setData] = React.useState(originData); - const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({}); - const [animating, setAnimating] = React.useState(false); - const [insertIndex, setInsertIndex] = React.useState(); + const [data, setData] = useState(originData); + const [closeMap, setCloseMap] = useState<{ [id: number]: boolean }>({}); + const [animating, setAnimating] = useState(false); + const [insertIndex, setInsertIndex] = useState(); - const listRef = React.useRef(); + const listRef = useRef(); const onClose = (id: string) => { setCloseMap({ @@ -150,7 +152,6 @@ const Demo = () => { }; const onAppear = (...args: any[]) => { - console.log('Appear:', args); setAnimating(false); }; @@ -174,20 +175,59 @@ const Demo = () => { }; return ( - +

Animate

+

Direction: Vertical

+

Current: {data.length} records

+ + + data={data} + data-id="list" + containerSize={200} + // itemSize={20} + itemKey="id" + // disabled={animating} + ref={listRef} + style={{ + border: '1px solid red', + boxSizing: 'border-box', + }} + // onSkipRender={onAppear} + // onItemRemove={onAppear} + > + {(item, index) => ( + + )} + + +
+
+
+
+

Direction: Horizontal

Current: {data.length} records

+ direction={IDirection.Horizontal} data={data} data-id="list" - height={200} - itemHeight={20} + containerSize={1000} + // itemSize={20} itemKey="id" // disabled={animating} ref={listRef} style={{ + width: 'min-content', border: '1px solid red', boxSizing: 'border-box', }} @@ -198,6 +238,7 @@ const Demo = () => { { )}
-
+ ); }; diff --git a/examples/basic.less b/examples/basic.less deleted file mode 100644 index 1939f7a6..00000000 --- a/examples/basic.less +++ /dev/null @@ -1,8 +0,0 @@ -.fixed-item { - border: 1px solid gray; - padding: 0 16px; - height: 32px; - line-height: 30px; - box-sizing: border-box; - display: inline-block; -} diff --git a/examples/basic.tsx b/examples/basic.tsx index 5d50c92a..9bd2fbbc 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -1,81 +1,76 @@ -/* eslint-disable jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */ -import * as React from 'react'; -import List, { ListRef } from '../src/List'; -import './basic.less'; +import React, { Component, useRef, useState } from 'react'; +import List from '../src/List'; +import { IDirection } from '../src/types'; +import { mockData } from './utils'; +import { ForwardMyItem } from './Item'; +import type { IItem } from './Item'; +import type { UIEventHandler } from 'react'; +import type { IListRef } from '../src/types'; -interface Item { - id: string; -} - -const MyItem: React.ForwardRefRenderFunction = ({ id }, ref) => ( - { - console.log('Click:', id); - }} - > - {id} - -); - -const ForwardMyItem = React.forwardRef(MyItem); - -class TestItem extends React.Component { +class TestItem extends Component { state = {}; render() { - return
{this.props.id}
; + const isHorizontalMode = this.props.direction === IDirection.Horizontal; + return ( +
+ {this.props.id} +
+ ); } } -const data: Item[] = []; -for (let i = 0; i < 1000; i += 1) { - data.push({ - id: String(i), - }); -} - const TYPES = [ { name: 'ref real dom element', type: 'dom', component: ForwardMyItem }, { name: 'ref react node', type: 'react', component: TestItem }, ]; -const onScroll: React.UIEventHandler = e => { - console.log('scroll:', e.currentTarget.scrollTop); +const onScroll: (direction: IDirection) => UIEventHandler = ( + direction: IDirection, +) => (e) => { + console.log( + 'scroll:', + e.currentTarget[direction === IDirection.Horizontal ? 'scrollLeft' : 'scrollTop'], + ); }; +const data = mockData(IDirection.Horizontal); + const Demo = () => { - const [destroy, setDestroy] = React.useState(false); - const [visible, setVisible] = React.useState(true); - const [type, setType] = React.useState('dom'); - const listRef = React.useRef(null); + const [destroy, setDestroy] = useState(false); + const [destroyHorizontal, setDestroyHorizontal] = useState(false); + const [visible, setVisible] = useState(true); + const [horizontalVisible, setHorizontalVisible] = useState(true); + const [verticalType, setVerticalType] = useState('dom'); + const [horizontalType, setHorizontalType] = useState('dom'); + const listVerticalRef = useRef(null); + const listHorizontalRef = useRef(null); return (

Basic

+

Direction: Vertical

{TYPES.map(({ name, type: nType }) => ( ))} - - {!destroy && ( + {(item, _, props) => { + return verticalType === 'dom' ? ( + + ) : ( + + ); + }} + + )} +
+ +

Direction: Horizontal

+ {TYPES.map(({ name, type: nType }) => ( + + ))} + + + + + + + + + + + + + + + + + + {!destroyHorizontal && ( + {(item, _, props) => - type === 'dom' ? ( - + horizontalType === 'dom' ? ( + ) : ( - + ) } @@ -243,5 +430,3 @@ const Demo = () => { }; export default Demo; - -/* eslint-enable */ diff --git a/examples/height.tsx b/examples/height.tsx index 47aafe1d..e7ea019a 100644 --- a/examples/height.tsx +++ b/examples/height.tsx @@ -1,59 +1,65 @@ -import * as React from 'react'; +import { IDirection } from '../src/types'; +import React, { StrictMode } from 'react'; import List from '../src/List'; - -interface Item { - id: number; - height: number; -} - -const MyItem: React.ForwardRefRenderFunction = ({ id, height }, ref) => { - return ( - - {id} - - ); -}; - -const ForwardMyItem = React.forwardRef(MyItem); - -const data: Item[] = []; -for (let i = 0; i < 100; i += 1) { - data.push({ - id: i, - height: 30 + (i % 2 ? 70 : 0), - }); -} +import { mockData } from './utils'; +import { ForwardMyItem } from './Item'; const Demo = () => { return ( - +

Dynamic Height

+ {(item) => ( + + )} + +
+
+
+
+
+
+

Dynamic Width

+ + - {item => } + {(item) => ( + + )}
-
+ ); }; diff --git a/examples/no-virtual.tsx b/examples/no-virtual.tsx deleted file mode 100644 index 793ab907..00000000 --- a/examples/no-virtual.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable arrow-body-style */ -import * as React from 'react'; -import List from '../src/List'; - -interface Item { - id: number; - height: number; -} - -const MyItem: React.FC = ({ id, height }, ref) => { - return ( - - {id} - - ); -}; - -const ForwardMyItem = React.forwardRef(MyItem as any); - -const data: Item[] = []; -for (let i = 0; i < 100; i += 1) { - data.push({ - id: i, - height: 30 + (i % 2 ? 20 : 0), - }); -} - -const Demo = () => { - return ( - -
-

Not Data

- - {item => } - - -

Less Count

- - {item => } - - -

Less Item Height

- - {item => } - - -

Without Height

- - {item => } - -
-
- ); -}; - -export default Demo; diff --git a/examples/noVirtual.tsx b/examples/noVirtual.tsx new file mode 100644 index 00000000..d606b28d --- /dev/null +++ b/examples/noVirtual.tsx @@ -0,0 +1,137 @@ +import React, { StrictMode } from 'react'; +import List from '../src/List'; +import { IDirection } from '../src/types'; +import { ForwardMyItem } from './Item'; +import { mockData } from './utils'; + +const Demo = () => { + return ( + +
+

Not Data

+

Direction: Vertical

+ + {item => } + + +

Direction: Horizontal

+ + {item => } + + +

Less Count

+

Direction: Vertical

+ + {item => } + +

Direction: Horizontal

+ + {item => } + + +

Less Item Size

+

Direction: Vertical

+ + {item => } + +

Direction: Horizontal

+ + {item => } + + +

Without Container Size

+

Direction: Vertical

+ + {item => } + +

Direction: Horizontal

+
+ + {item => } + +
+
+
+ ); +}; + +export default Demo; diff --git a/examples/staticItem.tsx b/examples/staticItem.tsx new file mode 100644 index 00000000..72fb6bf0 --- /dev/null +++ b/examples/staticItem.tsx @@ -0,0 +1,62 @@ +import { IDirection } from '../src/types'; +import React, { StrictMode } from 'react'; +import List from '../src/List'; +import { mockData } from './utils'; +import { ForwardMyItem } from './Item'; + +const Demo = () => { + return ( + +
+

Fixed Height

+ + + {(item) => } + +
+
+
+
+
+
+

Fixed Width

+ + + {(item) => ( + + )} + +
+
+ ); +}; + +export default Demo; diff --git a/examples/stringContainerSize.tsx b/examples/stringContainerSize.tsx new file mode 100644 index 00000000..fccf31c1 --- /dev/null +++ b/examples/stringContainerSize.tsx @@ -0,0 +1,155 @@ +import { IDirection } from '../src/types'; +import React, { StrictMode } from 'react'; +import List from '../src/List'; +import { mockData } from './utils'; +import { ForwardMyItem } from './Item'; + +const Demo = () => { + return ( + +
+

The wrapper element has a declared height: 500px

+
+ + {(item) => ( + + )} + +
+ +

The wrapper element has a declared height: calc(100vh - 200px)

+
+ + {(item) => ( + + )} + +
+ +

The virtual list has a valid string height

+ + {(item) => ( + + )} + + +

The wrapper element has a declared width: 800px

+
+ + {(item) => ( + + )} + +
+ +

The wrapper element has a declared width: calc(100vw - 300px)

+
+ + {(item) => ( + + )} + +
+ +

The virtual list has a valid string Width

+ + {(item) => ( + + )} + +
+
+ ); +}; + +export default Demo; diff --git a/examples/switch.tsx b/examples/switch.tsx index c918c8ae..c27e7857 100644 --- a/examples/switch.tsx +++ b/examples/switch.tsx @@ -1,130 +1,231 @@ -import * as React from 'react'; -import type { ListRef } from '../src/List'; +import { StrictMode, useRef, useState } from 'react'; import List from '../src/List'; - -interface Item { - id: number; -} - -const MyItem: React.FC = ({ id }, ref) => ( - - {id} - -); - -const ForwardMyItem = React.forwardRef(MyItem as any); - -function getData(count: number) { - const data: Item[] = []; - for (let i = 0; i < count; i += 1) { - data.push({ - id: i, - }); - } - return data; -} +import { IDirection } from '../src/types'; +import { ForwardMyItem } from './Item'; +import { mockData } from './utils'; +import type { IListRef } from '../src/types'; const Demo = () => { - const [height, setHeight] = React.useState(200); - const [data, setData] = React.useState(getData(20)); - const [fullHeight, setFullHeight] = React.useState(true); - const listRef = React.useRef(); + const [width, setWidth] = useState(500); + const [height, setHeight] = useState(200); + const [data, setData] = useState(mockData(IDirection.Vertical, 20)); + const [horizontalData, setHorizontalData] = useState(mockData(IDirection.Horizontal, 20)); + const [verticalFullSize, setVerticalFullSize] = useState(true); + const [horizontalFullSize, setHorizontalFullSize] = useState(true); + const listVerticalRef = useRef(); + const listHorizontalRef = useRef(); return ( - +

Switch

- Direction: Vertical

+
{ - setData(getData(Number(e.target.value))); + setData(mockData(IDirection.Vertical, Number(e.target.value))); }} > Data - - - +
{ setHeight(Number(e.target.value)); }} > - | Height + Height - - - - +
+ + + {(item, _, props) => } + + +

Direction: Horizontal

+
{ + setHorizontalData(mockData(IDirection.Horizontal, Number(e.target.value))); + }} + > + Data + + + + + + +
+
{ + setWidth(Number(e.target.value)); + }} + > + Width + + + +
+ + + + - {(item, _, props) => } + {(item, _, props) => ( + + )}
- + ); }; diff --git a/examples/utils.ts b/examples/utils.ts new file mode 100644 index 00000000..3977ff77 --- /dev/null +++ b/examples/utils.ts @@ -0,0 +1,14 @@ +import { IDirection } from "../src/types"; +import type { IItem } from "./Item"; + +export const mockData = (direction: IDirection, count = 100) => { + const data: IItem[] = []; + const base = direction === IDirection.Horizontal ? 120 : 30 + for (let i = 0; i < count; i += 1) { + data.push({ + id: i, + size: base + (i % 2 ? 70 : 0), + }); + } + return data +} \ No newline at end of file diff --git a/now.json b/now.json index 397041a0..9a7a1c5c 100644 --- a/now.json +++ b/now.json @@ -5,10 +5,15 @@ { "src": "package.json", "use": "@now/static-build", - "config": { "distDir": ".doc" } + "config": { + "distDir": ".doc" + } } ], "routes": [ - { "src": "/(.*)", "dest": "/dist/$1" } + { + "src": "/(.*)", + "dest": "/dist/$1" + } ] } \ No newline at end of file diff --git a/package.json b/package.json index efe01de1..ffb88b1b 100644 --- a/package.json +++ b/package.json @@ -68,4 +68,4 @@ "rc-resize-observer": "^1.0.0", "rc-util": "^5.15.0" } -} +} \ No newline at end of file diff --git a/src/Filler.tsx b/src/Filler.tsx index 4628b22c..b3ece68f 100644 --- a/src/Filler.tsx +++ b/src/Filler.tsx @@ -1,68 +1,121 @@ -import * as React from 'react'; import ResizeObserver from 'rc-resize-observer'; -import classNames from 'classnames'; +import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react'; +import type { OnResize } from 'rc-resize-observer'; +import type { ReactNode, CSSProperties } from 'react'; -export type InnerProps = Pick, 'role' | 'id'>; +export type IInnerProps = Omit, 'role' | 'id'>; interface FillerProps { prefixCls?: string; - /** Virtual filler height. Should be `count * itemMinHeight` */ - height: number; - /** Set offset of visible items. Should be the top of start item position */ + isHorizontalMode: boolean; + isVirtualMode: boolean; + /** Virtual filler width or height. Should be `count * (itemMinWidth or itemMinHeight)` */ + scrollSize: number; + /** Set offset of visible items. Should be the left or top of start item position */ offset?: number; - children: React.ReactNode; + children: ReactNode; - onInnerResize?: () => void; + innerProps?: IInnerProps; - innerProps?: InnerProps; + onInnerResize?: () => void; } /** * Fill component to provided the scroll content real height. */ -const Filler = React.forwardRef( +const Filler = forwardRef( ( - { height, offset, children, prefixCls, onInnerResize, innerProps }: FillerProps, + { + isHorizontalMode, + isVirtualMode, + scrollSize, + offset, + children, + prefixCls, + onInnerResize, + innerProps, + }: FillerProps, ref: React.Ref, ) => { - let outerStyle: React.CSSProperties = {}; + const wrapperRef = useRef(null); + + let outerStyle: CSSProperties = {}; - let innerStyle: React.CSSProperties = { + let innerStyle: CSSProperties = { display: 'flex', - flexDirection: 'column', + flexDirection: isHorizontalMode ? 'row' : 'column', }; + useLayoutEffect(() => { + if (isHorizontalMode && isVirtualMode && wrapperRef.current) { + const innerHeight = (wrapperRef.current.firstElementChild as HTMLDivElement).offsetHeight; + wrapperRef.current.style.height = `${innerHeight}px`; + } + }, [isHorizontalMode, isVirtualMode, wrapperRef]); + + const handleResize: OnResize = useCallback( + (params) => { + const { offsetWidth, offsetHeight } = params; + if (offsetWidth && offsetHeight && onInnerResize) { + onInnerResize(); + } + }, + [onInnerResize], + ); + + if (offset === undefined && isHorizontalMode) { + outerStyle = { + width: 'fit-content', + }; + } + if (offset !== undefined) { - outerStyle = { height, position: 'relative', overflow: 'hidden' }; + outerStyle = { + position: 'relative', + [isHorizontalMode ? 'width' : 'height']: scrollSize, + overflow: 'hidden', + }; innerStyle = { ...innerStyle, - transform: `translateY(${offset}px)`, + transform: `${isHorizontalMode ? `translateX(${offset}px` : `translateY(${offset}px`}`, position: 'absolute', - left: 0, - right: 0, - top: 0, + ...(isHorizontalMode + ? { + top: 0, + bottom: 0, + left: 0, + height: 'fit-content', + } + : { + left: 0, + right: 0, + top: 0, + }), + }; + } + + if (isVirtualMode) { + // padding for scroll bar, in case of stack. + outerStyle = { + ...outerStyle, + [isHorizontalMode ? 'marginBottom' : 'marginRight']: '8px', }; } + innerStyle = { + ...(innerProps?.style || {}), // custom style + ...innerStyle, + }; + + const outClassName = `${prefixCls ? `${prefixCls}-holder-outer` : 'holder-outer'}`; + const innerClassName = `${prefixCls ? `${prefixCls}-holder-inner` : 'holder-inner'}`; + return ( -
- { - if (offsetHeight && onInnerResize) { - onInnerResize(); - } - }} - > -
+
+ +
{children}
diff --git a/src/Item.tsx b/src/Item.tsx index 8a8cb6c5..c5f5049d 100644 --- a/src/Item.tsx +++ b/src/Item.tsx @@ -1,16 +1,22 @@ -import * as React from 'react'; +import { cloneElement, memo, useCallback } from 'react'; +import type { ReactElement } from 'react'; export interface ItemProps { - children: React.ReactElement; + children: ReactElement; setRef: (element: HTMLElement) => void; } -export function Item({ children, setRef }: ItemProps) { - const refFunc = React.useCallback(node => { - setRef(node); - }, []); +export const Item = ({ children, setRef }: ItemProps) => { + const updateRef = useCallback( + (node) => { + setRef(node); + }, + [setRef], + ); - return React.cloneElement(children, { - ref: refFunc, + return cloneElement(children, { + ref: updateRef, }); -} +}; + +export default memo(Item); diff --git a/src/List.tsx b/src/List.tsx index 59fd1694..afc4cc20 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,76 +1,57 @@ -import * as React from 'react'; -import { useRef, useState } from 'react'; -import classNames from 'classnames'; import Filler from './Filler'; -import type { InnerProps } from './Filler'; import ScrollBar from './ScrollBar'; -import type { RenderFunc, SharedConfig, GetKey } from './interface'; -import useChildren from './hooks/useChildren'; -import useHeights from './hooks/useHeights'; -import useScrollTo from './hooks/useScrollTo'; -import useDiffItem from './hooks/useDiffItem'; -import useFrameWheel from './hooks/useFrameWheel'; -import useMobileTouchMove from './hooks/useMobileTouchMove'; -import useOriginScroll from './hooks/useOriginScroll'; -import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; - -const EMPTY_DATA = []; - -const ScrollStyle: React.CSSProperties = { - overflowY: 'auto', - overflowAnchor: 'none', -}; - -export type ScrollAlign = 'top' | 'bottom' | 'auto'; -export type ScrollConfig = - | { - index: number; - align?: ScrollAlign; - offset?: number; - } - | { - key: React.Key; - align?: ScrollAlign; - offset?: number; - }; -export type ScrollTo = (arg: number | ScrollConfig) => void; -export type ListRef = { - scrollTo: ScrollTo; -}; - -export interface ListProps extends Omit, 'children'> { - prefixCls?: string; - children: RenderFunc; - data: T[]; - height?: number; - itemHeight?: number; - /** If not match virtual scroll condition, Set List still use height of container. */ - fullHeight?: boolean; - itemKey: React.Key | ((item: T) => React.Key); - component?: string | React.FC | React.ComponentClass; - /** Set `false` will always use real scroll instead of virtual one */ - virtual?: boolean; - - onScroll?: React.UIEventHandler; - /** Trigger when render list item changed */ - onVisibleChange?: (visibleList: T[], fullList: T[]) => void; - - /** Inject to inner container props. Only use when you need pass aria related data */ - innerProps?: InnerProps; -} - -export function RawList(props: ListProps, ref: React.Ref) { +import React, { + useImperativeHandle, + useMemo, + useRef, + useState, + forwardRef, + useLayoutEffect, + useCallback, +} from 'react'; +import { + useChildren, + useComponentStyle, + useContainerSize, + useEventListener, + useFallbackScroll, + useFrameWheel, + useGetKey, + useInitCache, + useIsEnableVirtual, + useIsHorizontalMode, + useIsVirtualMode, + useKeepInRange, + useLockScroll, + useMobileTouchMove, + useRenderData, + useScrollOffset, + useScrollTo, + useSyncScrollOffset, +} from './hooks'; +import { IDirection } from './types'; +import type { IContext, IListProps, IListRef } from './types'; +import type { IScrollBarRefProps } from './ScrollBar'; +import type { Ref, ReactElement } from 'react'; +import findDOMNode from 'rc-util/es/Dom/findDOMNode'; + +const EMPTY_DATA: unknown[] = []; +const DEFAULT_RENDER_COUNT = 10; + +export function RawList(props: IListProps, ref: Ref) { const { prefixCls = 'rc-virtual-list', className, - height, - itemHeight, - fullHeight = true, + containerSize: rawContainerSize, + itemSize: rawItemSize, + isStaticItem = false, + direction = IDirection.Vertical, + isFullSize = true, style, - data, + data: rawData, children, itemKey, - virtual, + isEnableVirtual: rawIsEnableVirtual = false, component: Component = 'div', onScroll, onVisibleChange, @@ -78,237 +59,177 @@ export function RawList(props: ListProps, ref: React.Ref) { ...restProps } = props; + const [containerSize, updateContainerSize] = useContainerSize(rawContainerSize); + + if (isStaticItem && !rawItemSize) { + throw new Error('itemsize property is Required when isStaticItem is true'); + } + + const itemSize = rawItemSize || 0; + + const data = useMemo(() => { + return rawData || (EMPTY_DATA as T[]); + }, [rawData]); // ================================= MISC ================================= - const useVirtual = !!(virtual !== false && height && itemHeight); - const inVirtual = useVirtual && data && itemHeight * data.length > height; + const isEnableVirtual = useIsEnableVirtual({ + isEnableVirtual: rawIsEnableVirtual, + containerSize, + itemSize, + }); // is enable virtual mode + const isVirtualMode = useIsVirtualMode({ + containerSize, + itemSize, + data, + isUseVirtual: isEnableVirtual, + }); // is in virtual mode + + const isHorizontalMode = useIsHorizontalMode(direction); - const [scrollTop, setScrollTop] = useState(0); + const [scrollOffset, setScrollOffset] = useScrollOffset(direction); // current scroll offset: scroll top or scroll left const [scrollMoving, setScrollMoving] = useState(false); - const mergedClassName = classNames(prefixCls, className); - const mergedData = data || EMPTY_DATA; + const defaultRenderCount = useMemo(() => { + let count = DEFAULT_RENDER_COUNT; + if (containerSize && itemSize) { + count = Math.max(Math.ceil(containerSize / itemSize), 0); + count = Math.min(count, data.length); + } + return count; + }, [containerSize, itemSize, data]); + + const mergedClassName = `${className || ''} ${prefixCls || ''}`; const componentRef = useRef(); const fillerInnerRef = useRef(); - const scrollBarRef = useRef(); // Hack on scrollbar to enable flash call + const scrollBarRef = useRef(); // Hack on scrollbar to enable flash call // =============================== Item Key =============================== - const getKey = React.useCallback>( - (item: T) => { - if (typeof itemKey === 'function') { - return itemKey(item); - } - return item?.[itemKey]; - }, - [itemKey], - ); + const getKey = useGetKey(itemKey); - const sharedConfig: SharedConfig = { + const context: IContext = { getKey, }; - // ================================ Scroll ================================ - function syncScrollTop(newTop: number | ((prev: number) => number)) { - setScrollTop((origin) => { - let value: number; - if (typeof newTop === 'function') { - value = newTop(origin); - } else { - value = newTop; - } - - const alignedTop = keepInRange(value); - - componentRef.current.scrollTop = alignedTop; - return alignedTop; - }); - } - // ================================ Legacy ================================ // Put ref here since the range is generate by follow - const rangeRef = useRef({ start: 0, end: mergedData.length }); - - const diffItemRef = useRef(); - const [diffItem] = useDiffItem(mergedData, getKey); - diffItemRef.current = diffItem; + const rangeRef = useRef({ startIndex: 0, endIndex: data.length }); - // ================================ Height ================================ - const [setInstanceRef, collectHeight, heights, heightUpdatedMark] = useHeights( + // ================================ init element and element size cache ================================ + const [updateElementCache, collectRectSize, getRectSizeByKey, updatedMark] = useInitCache( + isHorizontalMode, getKey, - null, - null, + undefined, + undefined, ); // ========================== Visible Calculation ========================= - const { scrollHeight, start, end, offset } = React.useMemo(() => { - if (!useVirtual) { - return { - scrollHeight: undefined, - start: 0, - end: mergedData.length - 1, - offset: undefined, - }; - } - - // Always use virtual scroll bar in avoid shaking - if (!inVirtual) { - return { - scrollHeight: fillerInnerRef.current?.offsetHeight || 0, - start: 0, - end: mergedData.length - 1, - offset: undefined, - }; - } - - let itemTop = 0; - let startIndex: number; - let startOffset: number; - let endIndex: number; - - const dataLen = mergedData.length; - for (let i = 0; i < dataLen; i += 1) { - const item = mergedData[i]; - const key = getKey(item); - - const cacheHeight = heights.get(key); - const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); - - // Check item top in the range - if (currentItemBottom >= scrollTop && startIndex === undefined) { - startIndex = i; - startOffset = itemTop; - } - - // Check item bottom in the range. We will render additional one item for motion usage - if (currentItemBottom > scrollTop + height && endIndex === undefined) { - endIndex = i; - } - - itemTop = currentItemBottom; - } - - // When scrollTop at the end but data cut to small count will reach this - if (startIndex === undefined) { - startIndex = 0; - startOffset = 0; - - endIndex = Math.ceil(height / itemHeight); - } - if (endIndex === undefined) { - endIndex = mergedData.length - 1; - } + const { scrollSize, startIndex, endIndex, offset } = useRenderData({ + isVirtualMode, + isEnableVirtual, + isHorizontalMode, + isStaticItem, + containerSize, + itemSize, + defaultRenderCount, + scrollOffset, + data, + updatedMark, + fillerInnerRef, + getKey, + getRectSizeByKey, + }); - // Give cache to improve scroll experience - endIndex = Math.min(endIndex + 1, mergedData.length); + rangeRef.current.startIndex = startIndex; + rangeRef.current.endIndex = endIndex; - return { - scrollHeight: itemTop, - start: startIndex, - end: endIndex, - offset: startOffset, - }; - }, [inVirtual, useVirtual, scrollTop, mergedData, heightUpdatedMark, height]); + // =============================== In Range =============================== + const maxScrollSize = Math.max((scrollSize || 0) - containerSize, 0); + const maxScrollSizeRef = useRef(maxScrollSize); + maxScrollSizeRef.current = maxScrollSize; - rangeRef.current.start = start; - rangeRef.current.end = end; + // keep scrollTop in range 0 ~ maxScrollHeight + const keepInRange = useKeepInRange(maxScrollSizeRef); - // =============================== In Range =============================== - const maxScrollHeight = scrollHeight - height; - const maxScrollHeightRef = useRef(maxScrollHeight); - maxScrollHeightRef.current = maxScrollHeight; - - function keepInRange(newScrollTop: number) { - let newTop = newScrollTop; - if (!Number.isNaN(maxScrollHeightRef.current)) { - newTop = Math.min(newTop, maxScrollHeightRef.current); - } - newTop = Math.max(newTop, 0); - return newTop; - } + // ================================ Scroll ================================ + const syncScrollOffset = useSyncScrollOffset( + isHorizontalMode, + componentRef, + keepInRange, + setScrollOffset, + ); - const isScrollAtTop = scrollTop <= 0; - const isScrollAtBottom = scrollTop >= maxScrollHeight; + const isScrollAtStart = scrollOffset <= 0; + const isScrollAtEnd = scrollOffset >= maxScrollSize; - const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom); + const lockScrollFn = useLockScroll(isScrollAtStart, isScrollAtEnd); // ================================ Scroll ================================ - function onScrollBar(newScrollTop: number) { - const newTop = newScrollTop; - syncScrollTop(newTop); - } + const handleScrollBarScroll = useCallback( + (newScrollOffset: number) => { + const newOffset = newScrollOffset; + syncScrollOffset(newOffset); + }, + [syncScrollOffset], + ); // When data size reduce. It may trigger native scroll event back to fit scroll position - function onFallbackScroll(e: React.UIEvent) { - const { scrollTop: newScrollTop } = e.currentTarget; - if (newScrollTop !== scrollTop) { - syncScrollTop(newScrollTop); - } - - // Trigger origin onScroll - onScroll?.(e); - } + const handleFallbackScroll = useFallbackScroll( + isHorizontalMode, + isVirtualMode, + scrollOffset, + syncScrollOffset, + onScroll, + ); - // Since this added in global,should use ref to keep update - const [onRawWheel, onFireFoxScroll] = useFrameWheel( - useVirtual, - isScrollAtTop, - isScrollAtBottom, - (offsetY) => { - syncScrollTop((top) => { - const newTop = top + offsetY; - return newTop; + // Since this added in global, should use ref to keep update + const onWheelDelta = useCallback( + (delta: number) => { + syncScrollOffset((_scrollOffset) => { + const newScrollOffset = _scrollOffset + delta; + return newScrollOffset; }); }, + [syncScrollOffset], + ); + const [onRawWheel, onFireFoxScroll] = useFrameWheel( + isHorizontalMode, + isEnableVirtual, + isScrollAtStart, + isScrollAtEnd, + onWheelDelta, ); // Mobile touch move - useMobileTouchMove(useVirtual, componentRef, (deltaY, smoothOffset) => { - if (originScroll(deltaY, smoothOffset)) { + useMobileTouchMove(isHorizontalMode, isEnableVirtual, componentRef, (delta, smoothOffset) => { + if (lockScrollFn(delta, smoothOffset)) { return false; } - - onRawWheel({ preventDefault() {}, deltaY } as WheelEvent); + const evt = ({ + preventDefault() {}, + [isHorizontalMode ? 'deltaX' : 'deltaY']: delta, + } as unknown) as WheelEvent; + onRawWheel(evt); return true; }); - useLayoutEffect(() => { - // Firefox only - function onMozMousePixelScroll(e: Event) { - if (useVirtual) { - e.preventDefault(); - } - } - - componentRef.current.addEventListener('wheel', onRawWheel); - componentRef.current.addEventListener('DOMMouseScroll', onFireFoxScroll as any); - componentRef.current.addEventListener('MozMousePixelScroll', onMozMousePixelScroll); - - return () => { - if (componentRef.current) { - componentRef.current.removeEventListener('wheel', onRawWheel); - componentRef.current.removeEventListener('DOMMouseScroll', onFireFoxScroll as any); - componentRef.current.removeEventListener( - 'MozMousePixelScroll', - onMozMousePixelScroll as any, - ); - } - }; - }, [useVirtual]); + useEventListener(isEnableVirtual, componentRef, onRawWheel, onFireFoxScroll); // ================================= Ref ================================== + const showScrollbar = useCallback(() => { + scrollBarRef.current?.showScrollbar(); + }, [scrollBarRef]); const scrollTo = useScrollTo( + isHorizontalMode, componentRef, - mergedData, - heights, - itemHeight, + data, + getRectSizeByKey, + itemSize, getKey, - collectHeight, - syncScrollTop, - () => { - scrollBarRef.current?.delayHidden(); - }, + collectRectSize, + syncScrollOffset, + showScrollbar, ); - React.useImperativeHandle(ref, () => ({ + useImperativeHandle(ref, () => ({ scrollTo, })); @@ -316,48 +237,73 @@ export function RawList(props: ListProps, ref: React.Ref) { /** We need told outside that some list not rendered */ useLayoutEffect(() => { if (onVisibleChange) { - const renderList = mergedData.slice(start, end + 1); + const renderList = data.slice(startIndex, endIndex + 1); - onVisibleChange(renderList, mergedData); + onVisibleChange(renderList, data); } - }, [start, end, mergedData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startIndex, endIndex, data]); // ================================ Render ================================ - const listChildren = useChildren(mergedData, start, end, setInstanceRef, children, sharedConfig); - - let componentStyle: React.CSSProperties = null; - if (height) { - componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle }; + const listChildren = useChildren( + data, + startIndex, + endIndex, + updateElementCache, + children, + context, + ); - if (useVirtual) { - componentStyle.overflowY = 'hidden'; + const componentStyle = useComponentStyle({ + isEnableVirtual, + scrollMoving, + isHorizontalMode, + rawContainerSize, + containerSize, + isFullSize, + }); - if (scrollMoving) { - componentStyle.pointerEvents = 'none'; + const wrapperStyle = useMemo(() => { + const field = isHorizontalMode ? 'width' : 'height'; + return { + ...(style || {}), + position: 'relative' as const, + ...(typeof rawContainerSize === 'string' ? { [field]: rawContainerSize } : {}), + }; + }, [style, rawContainerSize, isHorizontalMode]); + + const handleComponentRef = useCallback( + (compRef) => { + componentRef.current = compRef; + if (typeof rawContainerSize === 'string' && !containerSize) { + const field = isHorizontalMode ? 'offsetWidth' : 'offsetHeight'; + const domNode = findDOMNode(compRef); + const size = domNode?.[field]; + if (size) { + updateContainerSize(size); + } } - } - } + }, + [componentRef, rawContainerSize, containerSize, isHorizontalMode, updateContainerSize], + ); + + const _scrollSize = scrollSize || 0; return ( -
+
@@ -365,15 +311,16 @@ export function RawList(props: ListProps, ref: React.Ref) { - {useVirtual && ( + {isEnableVirtual && ( { setScrollMoving(true); }} @@ -386,10 +333,10 @@ export function RawList(props: ListProps, ref: React.Ref) { ); } -const List = React.forwardRef>(RawList); +const List = forwardRef>(RawList); List.displayName = 'List'; export default List as ( - props: ListProps & { ref?: React.Ref }, -) => React.ReactElement; + props: IListProps & { ref?: Ref }, +) => ReactElement; diff --git a/src/ScrollBar.tsx b/src/ScrollBar.tsx index f98005cc..bc5b4a68 100644 --- a/src/ScrollBar.tsx +++ b/src/ScrollBar.tsx @@ -1,233 +1,269 @@ -import * as React from 'react'; -import classNames from 'classnames'; -import raf from 'rc-util/lib/raf'; +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import raf from 'rc-util/es/raf'; +import type { MouseEvent as ReactMouseEvent, MouseEventHandler } from 'react'; +import type { ITimeoutHandher } from './types'; const MIN_SIZE = 20; -export interface ScrollBarProps { +export interface IScrollBarProps { prefixCls: string; - scrollTop: number; - scrollHeight: number; - height: number; + isHorizontalMode: boolean; + scrollOffset: number; + scrollSize: number; + containerSize?: number; count: number; - onScroll: (scrollTop: number) => void; + onScroll: (scrollOffset: number) => void; onStartMove: () => void; onStopMove: () => void; } -interface ScrollBarState { - dragging: boolean; - pageY: number; - startTop: number; - visible: boolean; +function getPageOffset(e: MouseEvent | ReactMouseEvent | TouchEvent, isHorizontalMode: boolean) { + const field = isHorizontalMode ? 'pageX' : 'pageY'; + return 'touches' in e ? e.touches[0][field] : e[field]; } -function getPageY(e: React.MouseEvent | MouseEvent | TouchEvent) { - return 'touches' in e ? e.touches[0].pageY : e.pageY; +export interface IScrollBarRefProps { + showScrollbar: () => void; } -export default class ScrollBar extends React.Component { - moveRaf: number = null; - - scrollbarRef = React.createRef(); - - thumbRef = React.createRef(); - - visibleTimeout: ReturnType = null; - - state: ScrollBarState = { - dragging: false, - pageY: null, - startTop: null, - visible: false, - }; - - componentDidMount() { - this.scrollbarRef.current.addEventListener('touchstart', this.onScrollbarTouchStart); - this.thumbRef.current.addEventListener('touchstart', this.onMouseDown); - } - - componentDidUpdate(prevProps: ScrollBarProps) { - if (prevProps.scrollTop !== this.props.scrollTop) { - this.delayHidden(); +const ScrollBar = forwardRef((props, ref) => { + const { + containerSize, + count, + scrollSize, + scrollOffset, + prefixCls, + isHorizontalMode, + onStartMove, + onStopMove, + onScroll, + } = props; + const moveRAFRef = useRef(); + const scrollBarRef = useRef(); + const thumbRef = useRef(); + const preScrollOffsetRef = useRef(0); + const hideScrollTimeoutRef = useRef((0 as unknown) as ITimeoutHandher); + + const [visible, updateVisible] = useState(false); + const [dragging, updateDragging] = useState(false); + const [state, updateState] = useState>({ + pageOffset: 0, + startOffset: 0, + }); + + const thumbSize = useMemo(() => { + let baseSize = (containerSize / count) * 10; + baseSize = Math.max(baseSize, MIN_SIZE); + baseSize = Math.min(baseSize, containerSize / 2); + return Math.floor(baseSize); + }, [count, containerSize]); + + const enableScrollRange = useMemo(() => { + return Math.max(scrollSize - containerSize, 0); + }, [scrollSize, containerSize]); + + const enableSizeRange = useMemo(() => { + return Math.max(containerSize - thumbSize, 0); + }, [thumbSize, containerSize]); + + const offset = useMemo(() => { + if (scrollOffset === 0 || enableScrollRange === 0) { + return 0; } - } + const ptg = scrollOffset / enableScrollRange; + return ptg * enableSizeRange; + }, [scrollOffset, enableScrollRange, enableSizeRange]); - componentWillUnmount() { - this.removeEvents(); - clearTimeout(this.visibleTimeout); - } + // Not show scrollbar when height is large than scrollHeight + const canScroll = useMemo(() => { + return scrollSize > containerSize; + }, [scrollSize, containerSize]); - delayHidden = () => { - clearTimeout(this.visibleTimeout); + const showScrollbar = useCallback(() => { + clearTimeout(hideScrollTimeoutRef.current); - this.setState({ visible: true }); - this.visibleTimeout = setTimeout(() => { - this.setState({ visible: false }); + updateVisible(true); + hideScrollTimeoutRef.current = setTimeout(() => { + clearTimeout(hideScrollTimeoutRef.current); + updateVisible(false); }, 2000); - }; + }, [hideScrollTimeoutRef, updateVisible]); - onScrollbarTouchStart = (e: TouchEvent) => { + const handleScrollbarTouchStart = useCallback((e: TouchEvent) => { e.preventDefault(); - }; + }, []); - onContainerMouseDown: React.MouseEventHandler = (e) => { + const handleContainerMouseDown: MouseEventHandler = useCallback((e) => { e.stopPropagation(); e.preventDefault(); - }; - - // ======================= Clean ======================= - patchEvents = () => { - window.addEventListener('mousemove', this.onMouseMove); - window.addEventListener('mouseup', this.onMouseUp); - - this.thumbRef.current.addEventListener('touchmove', this.onMouseMove); - this.thumbRef.current.addEventListener('touchend', this.onMouseUp); - }; - - removeEvents = () => { - window.removeEventListener('mousemove', this.onMouseMove); - window.removeEventListener('mouseup', this.onMouseUp); - - this.scrollbarRef.current?.removeEventListener('touchstart', this.onScrollbarTouchStart); - - if (this.thumbRef.current) { - this.thumbRef.current.removeEventListener('touchstart', this.onMouseDown); - this.thumbRef.current.removeEventListener('touchmove', this.onMouseMove); - this.thumbRef.current.removeEventListener('touchend', this.onMouseUp); - } - - raf.cancel(this.moveRaf); - }; + }, []); // ======================= Thumb ======================= - onMouseDown = (e: React.MouseEvent | TouchEvent) => { - const { onStartMove } = this.props; - - this.setState({ - dragging: true, - pageY: getPageY(e), - startTop: this.getTop(), + function handleMouseDown(e: MouseEvent | ReactMouseEvent | TouchEvent) { + updateDragging(true); + updateState({ + pageOffset: getPageOffset(e, isHorizontalMode), + startOffset: offset, }); + updateDragging(true); - onStartMove(); - this.patchEvents(); + onStartMove?.(); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + patchEvents(); e.stopPropagation(); e.preventDefault(); - }; + } - onMouseMove = (e: MouseEvent | TouchEvent) => { - const { dragging, pageY, startTop } = this.state; - const { onScroll } = this.props; + function handleMouseMove(e: MouseEvent | ReactMouseEvent | TouchEvent) { + const { pageOffset, startOffset } = state; - raf.cancel(this.moveRaf); + if (moveRAFRef.current) { + raf.cancel(moveRAFRef.current); + } if (dragging) { - const offsetY = getPageY(e) - pageY; - const newTop = startTop + offsetY; + const _offset = getPageOffset(e, isHorizontalMode) - pageOffset; + const newOffset = startOffset + _offset; - const enableScrollRange = this.getEnableScrollRange(); - const enableHeightRange = this.getEnableHeightRange(); - - const ptg = enableHeightRange ? newTop / enableHeightRange : 0; - const newScrollTop = Math.ceil(ptg * enableScrollRange); - this.moveRaf = raf(() => { - onScroll(newScrollTop); + const ptg = enableSizeRange ? newOffset / enableSizeRange : 0; + const newScrollOffset = Math.ceil(ptg * enableScrollRange); + moveRAFRef.current = raf(() => { + onScroll(newScrollOffset); }); } - }; - - onMouseUp = () => { - const { onStopMove } = this.props; - this.setState({ dragging: false }); + } + function handleMouseUp() { + updateDragging(false); onStopMove(); - this.removeEvents(); - }; - - // ===================== Calculate ===================== - getSpinHeight = () => { - const { height, count } = this.props; - let baseHeight = (height / count) * 10; - baseHeight = Math.max(baseHeight, MIN_SIZE); - baseHeight = Math.min(baseHeight, height / 2); - return Math.floor(baseHeight); - }; - - getEnableScrollRange = () => { - const { scrollHeight, height } = this.props; - return scrollHeight - height || 0; - }; - - getEnableHeightRange = () => { - const { height } = this.props; - const spinHeight = this.getSpinHeight(); - return height - spinHeight || 0; - }; - - getTop = () => { - const { scrollTop } = this.props; - const enableScrollRange = this.getEnableScrollRange(); - const enableHeightRange = this.getEnableHeightRange(); - if (scrollTop === 0 || enableScrollRange === 0) { - return 0; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + removeEvents(); + } + + function patchEvents() { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + if (thumbRef.current) { + thumbRef.current.addEventListener('touchmove', handleMouseMove); + thumbRef.current.addEventListener('touchend', handleMouseUp); } - const ptg = scrollTop / enableScrollRange; - return ptg * enableHeightRange; - }; + } - // Not show scrollbar when height is large than scrollHeight - showScroll = (): boolean => { - const { height, scrollHeight } = this.props; - return scrollHeight > height; - }; - - // ====================== Render ======================= - render() { - const { dragging, visible } = this.state; - const { prefixCls } = this.props; - const spinHeight = this.getSpinHeight(); - const top = this.getTop(); - - const canScroll = this.showScroll(); - const mergedVisible = canScroll && visible; - - return ( + function removeEvents() { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + + scrollBarRef.current?.removeEventListener('touchstart', handleScrollbarTouchStart); + + if (thumbRef.current) { + thumbRef.current.removeEventListener('touchstart', handleMouseDown); + thumbRef.current.removeEventListener('touchmove', handleMouseMove); + thumbRef.current.removeEventListener('touchend', handleMouseUp); + } + + if (moveRAFRef.current) { + raf.cancel(moveRAFRef.current); + } + } + + useLayoutEffect(() => { + if (scrollBarRef.current) { + scrollBarRef.current.addEventListener('touchstart', handleScrollbarTouchStart); + } + + if (thumbRef.current) { + thumbRef.current.addEventListener('touchstart', handleMouseDown); + } + + if (preScrollOffsetRef.current !== scrollOffset) { + showScrollbar(); + preScrollOffsetRef.current = scrollOffset; + } + + return () => { + removeEvents(); + clearTimeout(hideScrollTimeoutRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + scrollBarRef, + thumbRef, + preScrollOffsetRef, + hideScrollTimeoutRef, + showScrollbar, + handleScrollbarTouchStart, + handleMouseDown, + ]); + + useImperativeHandle( + ref, + function () { + return { + showScrollbar, + }; + }, + [showScrollbar], + ); + + const mergedVisible = canScroll && visible; + const className = `${prefixCls ? `${prefixCls}-scrollbar` : 'scrollbar'} ${ + canScroll ? `${prefixCls}-scrollbar-show` : '' + }`; + const thumbClassName = `${prefixCls ? `${prefixCls}-scrollbar-thumb` : 'scrollbar-thumb'} ${ + dragging ? `${prefixCls}-scrollbar-thumb-moving` : '' + }`; + const thumbStyle = isHorizontalMode + ? { + width: thumbSize, + height: '100%', + left: offset, + top: 0, + } + : { + width: '100%', + height: thumbSize, + left: 0, + top: offset, + }; + + return ( +
-
-
- ); - } -} + onMouseDown={handleMouseDown} + /> +
+ ); +}); + +export default ScrollBar; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000..c00ff577 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,19 @@ +export { default as useContainerSize } from './useContainerSize'; +export { default as useChildren } from './useChildren'; +export { default as useComponentStyle } from './useComponentStyle'; +export { default as useDiffItem } from './useDiffItem'; +export { default as useFrameWheel } from './useFrameWheel'; +export { default as useGetKey } from './useGetKey'; +export { default as useInitCache } from './useInitCache'; +export { default as useIsEnableVirtual } from './useIsEnableVirtual'; +export { default as useIsHorizontalMode } from './useIsHorizontalMode'; +export { default as useIsVirtualMode } from './useIsVirtualMode'; +export { default as useLockScroll } from './useLockScroll'; +export { default as useMobileTouchMove } from './useMobileTouchMove'; +export { default as useScrollOffset } from './useScrollOffset'; +export { default as useScrollTo } from './useScrollTo'; +export { default as useKeepInRange } from './useKeepInRange'; +export { default as useSyncScrollOffset } from './useSyncScrollOffset'; +export { default as useFallbackScroll } from './useFallbackScroll'; +export { default as useEventListener } from './useEventListener'; +export { default as useRenderData } from './useRenderData'; diff --git a/src/hooks/useChildren.tsx b/src/hooks/useChildren.tsx index d22ea4c0..6e41d143 100644 --- a/src/hooks/useChildren.tsx +++ b/src/hooks/useChildren.tsx @@ -1,26 +1,29 @@ -import * as React from 'react'; -import type { SharedConfig, RenderFunc } from '../interface'; +import React, { useMemo } from 'react'; import { Item } from '../Item'; +import type { ReactElement } from 'react'; +import type { IContext, IRenderFunc } from '../types'; export default function useChildren( list: T[], startIndex: number, endIndex: number, - setNodeRef: (item: T, element: HTMLElement) => void, - renderFunc: RenderFunc, - { getKey }: SharedConfig, + cacheElement: (item: T, element: HTMLElement) => void, + renderFunc: IRenderFunc, + { getKey }: IContext, ) { - return list.slice(startIndex, endIndex + 1).map((item, index) => { - const eleIndex = startIndex + index; - const node = renderFunc(item, eleIndex, { - // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {}, - }) as React.ReactElement; + return useMemo(() => { + return list.slice(startIndex, endIndex + 1).map((item, index) => { + const eleIndex = startIndex + index; + const element = renderFunc(item, eleIndex, { + // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {}, + }) as ReactElement; - const key = getKey(item); - return ( - setNodeRef(item, ele)}> - {node} - - ); - }); + const key = getKey(item); + return ( + cacheElement(item, ele)}> + {element} + + ); + }); + }, [list, startIndex, endIndex, cacheElement, getKey]); } diff --git a/src/hooks/useComponentStyle.ts b/src/hooks/useComponentStyle.ts new file mode 100644 index 00000000..065f16fe --- /dev/null +++ b/src/hooks/useComponentStyle.ts @@ -0,0 +1,81 @@ +import { useMemo } from 'react'; +import type { CSSProperties } from 'react'; +import type { IUseComponentStyle } from '../types'; + +const ScrollHorizontalStyle: CSSProperties = { + overflowX: 'auto', + overflowAnchor: 'none', +}; + +const ScrollVerticalStyle: CSSProperties = { + overflowY: 'auto', + overflowAnchor: 'none', +}; + +const useComponentStyle = ({ + isEnableVirtual, + scrollMoving, + isHorizontalMode, + rawContainerSize, + containerSize, + isFullSize, +}: IUseComponentStyle): CSSProperties | null => { + return useMemo(() => { + let componentStyle: CSSProperties | null = null; + const scrollStyle = isHorizontalMode ? ScrollHorizontalStyle : ScrollVerticalStyle; + const size = typeof rawContainerSize === 'string' ? '100%' : containerSize; + + if (isHorizontalMode) { + if (rawContainerSize) { + componentStyle = { + [isFullSize ? 'width' : 'maxWidth']: size, + ...scrollStyle + }; + + if (isEnableVirtual) { + componentStyle = { + ...componentStyle, + overflowX: 'hidden', + }; + + if (scrollMoving) { + componentStyle = { + ...componentStyle, + pointerEvents: 'none', + }; + } + } + } + /** In case of height to zone when there is no children */ + componentStyle = { + ...componentStyle, + minHeight: '100%', + }; + return componentStyle; + } + + if (rawContainerSize) { + componentStyle = { + [isFullSize ? 'height' : 'maxHeight']: size, + ...scrollStyle + }; + + if (isEnableVirtual) { + componentStyle = { + ...componentStyle, + overflowY: 'hidden', + }; + + if (scrollMoving) { + componentStyle = { + ...componentStyle, + pointerEvents: 'none', + }; + } + } + } + return componentStyle; + }, [isEnableVirtual, scrollMoving, isHorizontalMode, rawContainerSize, containerSize, isFullSize]); +}; + +export default useComponentStyle; diff --git a/src/hooks/useContainerSize.ts b/src/hooks/useContainerSize.ts new file mode 100644 index 00000000..bcd7eecb --- /dev/null +++ b/src/hooks/useContainerSize.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; +import type { Dispatch } from 'react'; + +const useContainerSize = (rawContainerSize?: number | string): [number, Dispatch>] => { + const [containerSize, updateContainerSize] = useState(() => { + if(!rawContainerSize || typeof rawContainerSize === 'string') { + return 0; + } + return rawContainerSize; + }); + + return [containerSize, updateContainerSize]; +}; + +export default useContainerSize; diff --git a/src/hooks/useDiffItem.ts b/src/hooks/useDiffItem.ts index ac46a159..c54d15ee 100644 --- a/src/hooks/useDiffItem.ts +++ b/src/hooks/useDiffItem.ts @@ -1,23 +1,23 @@ -import * as React from 'react'; -import { findListDiffIndex } from '../utils/algorithmUtil'; -import type { GetKey } from '../interface'; +import { findListDiffIndex } from '../utils'; +import { useEffect, useState } from 'react'; +import type { IGetKey } from '../types'; export default function useDiffItem( data: T[], - getKey: GetKey, - onDiff?: (diffIndex: number) => void, -): [T] { - const [prevData, setPrevData] = React.useState(data); - const [diffItem, setDiffItem] = React.useState(null); + getKey: IGetKey, + onDiff?: (diffIndex: number) => void +): [T | null] { + const [prevData, setPrevData] = useState(data); + const [diffItem, setDiffItem] = useState(null); - React.useEffect(() => { + useEffect(() => { const diff = findListDiffIndex(prevData || [], data || [], getKey); if (diff?.index !== undefined) { onDiff?.(diff.index); setDiffItem(data[diff.index]); } setPrevData(data); - }, [data]); + }, [prevData, data, getKey, onDiff, setPrevData]); return [diffItem]; } diff --git a/src/hooks/useEventListener.ts b/src/hooks/useEventListener.ts new file mode 100644 index 00000000..19ac1582 --- /dev/null +++ b/src/hooks/useEventListener.ts @@ -0,0 +1,35 @@ +import { useLayoutEffect } from 'react'; +import type { RefObject } from 'react'; +import type { FireFoxDOMMouseScrollEvent } from '../types'; + +const useEventListener = ( + isEnableVirtual: boolean, + componentRef: RefObject, + onRawWheel: (e: WheelEvent) => void, + onFireFoxScroll: (e: FireFoxDOMMouseScrollEvent) => void +) => { + useLayoutEffect(() => { + // Firefox only + function onMozMousePixelScroll(e: Event) { + if (isEnableVirtual) { + e.preventDefault(); + } + } + + if (componentRef.current) { + componentRef.current.addEventListener('wheel', onRawWheel); + componentRef.current.addEventListener('DOMMouseScroll', onFireFoxScroll as any); + componentRef.current.addEventListener('MozMousePixelScroll', onMozMousePixelScroll); + } + + return () => { + if (componentRef.current) { + componentRef.current.removeEventListener('wheel', onRawWheel); + componentRef.current.removeEventListener('DOMMouseScroll', onFireFoxScroll as any); + componentRef.current.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll as any); + } + }; + }, [isEnableVirtual, componentRef, onRawWheel, onFireFoxScroll]); +}; + +export default useEventListener; diff --git a/src/hooks/useFallbackScroll.ts b/src/hooks/useFallbackScroll.ts new file mode 100644 index 00000000..2463630a --- /dev/null +++ b/src/hooks/useFallbackScroll.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import type { UIEvent, UIEventHandler } from 'react'; + +const useFallbackScroll = ( + isHorizontalMode: boolean, + isVirtualMode: boolean, + scrollOffset: number, + syncScrollOffset: (newOffset: number | ((prev: number) => number)) => void, + onScroll?: UIEventHandler +) => { + return useCallback( + (e: UIEvent) => { + // No need to sync scroll offset when list is not in virtual mode + if (isVirtualMode) { + const newScrollOffset = e.currentTarget[isHorizontalMode ? 'scrollLeft' : 'scrollTop']; + if (newScrollOffset !== scrollOffset) { + syncScrollOffset(newScrollOffset); + } + } + + // Trigger origin scroll event callback + onScroll?.(e); + }, + [isHorizontalMode, isVirtualMode, scrollOffset, syncScrollOffset, onScroll] + ); +}; + +export default useFallbackScroll; diff --git a/src/hooks/useFrameWheel.ts b/src/hooks/useFrameWheel.ts index 6fdf1124..220986cd 100644 --- a/src/hooks/useFrameWheel.ts +++ b/src/hooks/useFrameWheel.ts @@ -1,61 +1,70 @@ -import { useRef } from 'react'; -import raf from 'rc-util/lib/raf'; -import isFF from '../utils/isFirefox'; -import useOriginScroll from './useOriginScroll'; - -interface FireFoxDOMMouseScrollEvent { - detail: number; - preventDefault: Function; -} +import { useCallback, useRef } from 'react'; +import { isFF } from '../utils'; +import raf from 'rc-util/es/raf'; +import useLockScroll from './useLockScroll'; +import type { FireFoxDOMMouseScrollEvent } from '../types'; export default function useFrameWheel( + isHorizontalMode: boolean, inVirtual: boolean, isScrollAtTop: boolean, isScrollAtBottom: boolean, - onWheelDelta: (offset: number) => void, + onWheelDelta: (offset: number) => void ): [(e: WheelEvent) => void, (e: FireFoxDOMMouseScrollEvent) => void] { const offsetRef = useRef(0); - const nextFrameRef = useRef(null); + const frameRAFRef = useRef(0); // Firefox patch - const wheelValueRef = useRef(null); + const wheelValueRef = useRef(0); const isMouseScrollRef = useRef(false); // Scroll status sync - const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom); + const lockScrollFn = useLockScroll(isScrollAtTop, isScrollAtBottom); - function onWheel(event: WheelEvent) { - if (!inVirtual) return; + const onWheel = useCallback( + (event: WheelEvent) => { + if (!inVirtual) { + return; + } - raf.cancel(nextFrameRef.current); + raf.cancel(frameRAFRef.current); - const { deltaY } = event; - offsetRef.current += deltaY; - wheelValueRef.current = deltaY; + const delta = event[isHorizontalMode ? 'deltaX' : 'deltaY']; + offsetRef.current += delta; + wheelValueRef.current = delta; - // Do nothing when scroll at the edge, Skip check when is in scroll - if (originScroll(deltaY)) return; + // Do nothing when scroll at the edge, Skip check when is in scroll + if (lockScrollFn(delta)) { + return; + } - // Proxy of scroll events - if (!isFF) { - event.preventDefault(); - } + // Proxy of scroll events + if (!isFF) { + event.preventDefault(); + } - nextFrameRef.current = raf(() => { - // Patch a multiple for Firefox to fix wheel number too small - // ref: https://github.com/ant-design/ant-design/issues/26372#issuecomment-679460266 - const patchMultiple = isMouseScrollRef.current ? 10 : 1; - onWheelDelta(offsetRef.current * patchMultiple); - offsetRef.current = 0; - }); - } + frameRAFRef.current = raf(() => { + // Patch a multiple for Firefox to fix wheel number too small + // ref: https://github.com/ant-design/ant-design/issues/26372#issuecomment-679460266 + const patchMultiple = isMouseScrollRef.current ? 10 : 1; + onWheelDelta(offsetRef.current * patchMultiple); + offsetRef.current = 0; + }); + }, + [inVirtual, isHorizontalMode, offsetRef, frameRAFRef, wheelValueRef, isMouseScrollRef, lockScrollFn, onWheelDelta] + ); // A patch for firefox - function onFireFoxScroll(event: FireFoxDOMMouseScrollEvent) { - if (!inVirtual) return; + const onFireFoxScroll = useCallback( + (event: FireFoxDOMMouseScrollEvent) => { + if (!inVirtual) { + return; + } - isMouseScrollRef.current = event.detail === wheelValueRef.current; - } + isMouseScrollRef.current = event.detail === wheelValueRef.current; + }, + [inVirtual, isMouseScrollRef, wheelValueRef] + ); return [onWheel, onFireFoxScroll]; } diff --git a/src/hooks/useGetKey.ts b/src/hooks/useGetKey.ts new file mode 100644 index 00000000..6d592c67 --- /dev/null +++ b/src/hooks/useGetKey.ts @@ -0,0 +1,17 @@ +import { useCallback } from 'react'; +import type { Key } from 'react'; +import type { IGetKey } from '../types'; + +const useGetKey = (itemKey: Key | ((item: T) => Key)) => { + const getKey = useCallback>((item: T) => { + if (typeof itemKey === 'function') { + return itemKey(item); + } + return item?.[itemKey]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return getKey; +}; + +export default useGetKey; diff --git a/src/hooks/useHeights.tsx b/src/hooks/useHeights.tsx deleted file mode 100644 index 9a6bb832..00000000 --- a/src/hooks/useHeights.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from 'react'; -import { useRef, useEffect } from 'react'; -import findDOMNode from 'rc-util/lib/Dom/findDOMNode'; -import raf from 'rc-util/lib/raf'; -import type { GetKey } from '../interface'; -import CacheMap from '../utils/CacheMap'; - -export default function useHeights( - getKey: GetKey, - onItemAdd?: (item: T) => void, - onItemRemove?: (item: T) => void, -): [(item: T, instance: HTMLElement) => void, () => void, CacheMap, number] { - const [updatedMark, setUpdatedMark] = React.useState(0); - const instanceRef = useRef(new Map()); - const heightsRef = useRef(new CacheMap()); - const collectRafRef = useRef(); - - function cancelRaf() { - raf.cancel(collectRafRef.current); - } - - function collectHeight() { - cancelRaf(); - - collectRafRef.current = raf(() => { - instanceRef.current.forEach((element, key) => { - if (element && element.offsetParent) { - const htmlElement = findDOMNode(element); - const { offsetHeight } = htmlElement; - if (heightsRef.current.get(key) !== offsetHeight) { - heightsRef.current.set(key, htmlElement.offsetHeight); - } - } - }); - - // Always trigger update mark to tell parent that should re-calculate heights when resized - setUpdatedMark((c) => c + 1); - }); - } - - function setInstanceRef(item: T, instance: HTMLElement) { - const key = getKey(item); - const origin = instanceRef.current.get(key); - - if (instance) { - instanceRef.current.set(key, instance); - collectHeight(); - } else { - instanceRef.current.delete(key); - } - - // Instance changed - if (!origin !== !instance) { - if (instance) { - onItemAdd?.(item); - } else { - onItemRemove?.(item); - } - } - } - - useEffect(() => { - return cancelRaf; - }, []); - - return [setInstanceRef, collectHeight, heightsRef.current, updatedMark]; -} diff --git a/src/hooks/useInitCache.tsx b/src/hooks/useInitCache.tsx new file mode 100644 index 00000000..b69afc1c --- /dev/null +++ b/src/hooks/useInitCache.tsx @@ -0,0 +1,200 @@ +import { useRef, useEffect, useCallback, useState } from 'react'; +import findDOMNode from 'rc-util/es/Dom/findDOMNode'; +import raf from 'rc-util/es/raf'; +import type { Key } from 'react'; +import type { IGetKey } from '../types'; + +const useHeightCache = () => { + const cacheRef = useRef(new Map()); + const getHeightByKey = useCallback( + (key: Key): number | undefined => { + return cacheRef.current.get(key); + }, + [cacheRef], + ); + const udpateHeightByKey = useCallback( + (key: Key, height: number) => { + cacheRef.current.set(key, height); + }, + [cacheRef], + ); + return [getHeightByKey, udpateHeightByKey] as const; +}; + +const useWidthCache = () => { + const cacheRef = useRef(new Map()); + const getWidthByKey = useCallback( + (key: Key): number | undefined => { + return cacheRef.current.get(key); + }, + [cacheRef], + ); + const udpateWidthByKey = useCallback( + (key: Key, width: number) => { + cacheRef.current.set(key, width); + }, + [cacheRef], + ); + return [getWidthByKey, udpateWidthByKey] as const; +}; + +const useRectSizeCache = (isHorizontalMode: boolean) => { + const [getHeightByKey, updateHeightByKey] = useHeightCache(); + const [getWidthByKey, updateWidthByKey] = useWidthCache(); + + const getRectSizeByKey = useCallback( + (key: Key) => { + const getter = isHorizontalMode ? getWidthByKey : getHeightByKey; + return getter(key); + }, + [isHorizontalMode, getWidthByKey, getHeightByKey], + ); + + const updateRectSizeByKey = useCallback( + (key: Key, size: number) => { + const setter = isHorizontalMode ? updateWidthByKey : updateHeightByKey; + return setter(key, size); + }, + [isHorizontalMode, updateWidthByKey, updateHeightByKey], + ); + + return [getRectSizeByKey, updateRectSizeByKey] as const; +}; + +const useCollectRAF = () => { + const collectRAFRef = useRef(); + + const cancelRAF = useCallback(() => { + if (collectRAFRef.current) { + raf.cancel(collectRAFRef.current); + } + }, [collectRAFRef]); + + const updateCollectRAF = useCallback( + (rafId: number) => { + collectRAFRef.current = rafId; + }, + [collectRAFRef], + ); + + return [cancelRAF, updateCollectRAF] as const; +}; + +function useElementCache() { + const elementRef = useRef(new Map()); + + const forEachElement = useCallback( + (forEachFn) => { + elementRef.current.forEach(forEachFn); + }, + [elementRef], + ); + + const getElementByKey = useCallback( + (key: Key) => { + return elementRef.current.get(key); + }, + [elementRef], + ); + + const updateElementByKey = useCallback( + (key: Key, element: HTMLElement) => { + return elementRef.current.set(key, element); + }, + [elementRef], + ); + + const deleteElementByKey = useCallback( + (key: Key) => { + return elementRef.current.delete(key); + }, + [elementRef], + ); + + return [getElementByKey, forEachElement, updateElementByKey, deleteElementByKey] as const; +} + +export default function useInitCache( + isHorizontalMode: boolean, + getKey: IGetKey, + onItemAdd?: (item: T) => void, + onItemRemove?: (item: T) => void, +): [ + (item: T, instance: HTMLElement) => void, + () => void, + (key: Key) => number | undefined, + number, +] { + const [updatedMark, setUpdatedMark] = useState(0); + const [ + getElementByKey, + forEachElement, + updateElementByKey, + deleteElementByKey, + ] = useElementCache(); + const [getRectSizeByKey, setRectSizeByKey] = useRectSizeCache(isHorizontalMode); + const [cancelRAF, updateCollectRAF] = useCollectRAF(); + + const collectRectSize = useCallback(() => { + cancelRAF(); + + const rafId = raf(() => { + forEachElement((element: HTMLElement, key: Key) => { + if (element && element.offsetParent) { + const htmlElement = findDOMNode(element); + const offsetSize = htmlElement[isHorizontalMode ? 'offsetWidth' : 'offsetHeight']; + // update item size if item is static and itemSize props is undefined + if (getRectSizeByKey(key) !== offsetSize) { + setRectSizeByKey(key, offsetSize); + } + } + }); + // refresh + // Always trigger update mark to tell parent that should re-calculate site when resized + setUpdatedMark((c) => c + 1); + }); + updateCollectRAF(rafId); + }, [ + isHorizontalMode, + forEachElement, + getRectSizeByKey, + setRectSizeByKey, + cancelRAF, + updateCollectRAF, + ]); + + const updateElement = useCallback( + (item: T, element: HTMLElement) => { + const key = getKey(item); + const cachedElement = getElementByKey(key); + + if (element) { + updateElementByKey(key, element); + collectRectSize(); + } else { + deleteElementByKey(key); + } + + // Element changed + if (!cachedElement !== !element) { + const cb = element ? onItemAdd : onItemRemove; + cb?.(item); + } + }, + [ + getKey, + getElementByKey, + updateElementByKey, + collectRectSize, + deleteElementByKey, + onItemAdd, + onItemRemove, + ], + ); + + useEffect(() => { + return cancelRAF; + }, [cancelRAF]); + + return [updateElement, collectRectSize, getRectSizeByKey, updatedMark]; +} diff --git a/src/hooks/useIsEnableVirtual.ts b/src/hooks/useIsEnableVirtual.ts new file mode 100644 index 00000000..244737b7 --- /dev/null +++ b/src/hooks/useIsEnableVirtual.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; +import type { IUseIsEnableVirtualParams } from '../types'; + +const useIsEnableVirtual = (params: IUseIsEnableVirtualParams) => { + const { isEnableVirtual, containerSize, itemSize } = params; + + const enableVirtual = useMemo(() => { + return Boolean(isEnableVirtual && containerSize && itemSize); + }, [isEnableVirtual, containerSize, itemSize]); + + return enableVirtual; +}; + +export default useIsEnableVirtual; diff --git a/src/hooks/useIsHorizontalMode.ts b/src/hooks/useIsHorizontalMode.ts new file mode 100644 index 00000000..aa0ddbad --- /dev/null +++ b/src/hooks/useIsHorizontalMode.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; +import { IDirection } from '../types'; + +const useIsHorizontalMode = (direction: IDirection) => { + const isHorizontalMode = useMemo(() => { + return direction === IDirection.Horizontal; + }, [direction]); + return isHorizontalMode; +}; + +export default useIsHorizontalMode; diff --git a/src/hooks/useIsVirtualMode.ts b/src/hooks/useIsVirtualMode.ts new file mode 100644 index 00000000..cd86e697 --- /dev/null +++ b/src/hooks/useIsVirtualMode.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; + +export interface IUseIsVirtualModeParams { + containerSize?: number; + itemSize?: number; + data: T[]; + isUseVirtual: boolean; +} + +const useIsVirtualMode = ({ + containerSize: rawContainerSize, + itemSize: rawItemSize, + data, + isUseVirtual, +}: IUseIsVirtualModeParams): boolean => { + const containerSize = rawContainerSize || 0; + const itemSize = rawItemSize || 0; + + const isVirtualMode = useMemo(() => { + return isUseVirtual && data && itemSize * data.length > containerSize; + }, [isUseVirtual, data, itemSize, containerSize]); + + return isVirtualMode; +}; + +export default useIsVirtualMode; diff --git a/src/hooks/useKeepInRange.ts b/src/hooks/useKeepInRange.ts new file mode 100644 index 00000000..3ff2c0e1 --- /dev/null +++ b/src/hooks/useKeepInRange.ts @@ -0,0 +1,19 @@ +import { useCallback } from 'react'; +import type { MutableRefObject } from 'react'; + +// keep scrollTop in range 0 ~ maxScrollHeight +const useKeepInRange = (maxScrollSizeRef: MutableRefObject) => { + return useCallback( + (newScrollOffset: number) => { + let newOffset = newScrollOffset; + if (!Number.isNaN(maxScrollSizeRef.current)) { + newOffset = Math.min(newOffset, maxScrollSizeRef.current); + } + newOffset = Math.max(newOffset, 0); + return newOffset; + }, + [maxScrollSizeRef] + ); +}; + +export default useKeepInRange; diff --git a/src/hooks/useLockScroll.ts b/src/hooks/useLockScroll.ts new file mode 100644 index 00000000..38e70d1f --- /dev/null +++ b/src/hooks/useLockScroll.ts @@ -0,0 +1,57 @@ +import { useCallback, useRef } from 'react'; + +const useLockScroll = (isScrollAtTop: boolean, isScrollAtBottom: boolean) => { + // Do lock for a wheel when scrolling + const lockRef = useRef(false); + const lockTimeoutRef = useRef>(); + + const localClearTimeout = useCallback((timeoutHd?: ReturnType) => { + if (timeoutHd) { + clearTimeout(timeoutHd); + } + }, []); + + const lockScroll = useCallback(() => { + localClearTimeout(lockTimeoutRef.current); + + lockRef.current = true; + + lockTimeoutRef.current = setTimeout(() => { + localClearTimeout(lockTimeoutRef.current); + lockRef.current = false; + }, 50); + }, [lockTimeoutRef, lockRef, localClearTimeout]); + + // Pass to ref since global add is in closure + const scrollPingRef = useRef({ + top: isScrollAtTop, + bottom: isScrollAtBottom, + }); + scrollPingRef.current.top = isScrollAtTop; + scrollPingRef.current.bottom = isScrollAtBottom; + + const lockScrollFn = useCallback( + (delta: number, smoothOffset = false) => { + const originScroll = + // Pass origin wheel when on the top + (delta < 0 && scrollPingRef.current.top) || + // Pass origin wheel when on the bottom + (delta > 0 && scrollPingRef.current.bottom); + + if (smoothOffset && originScroll) { + // No need lock anymore when it's smooth offset from touchMove interval + localClearTimeout(lockTimeoutRef.current); + lockRef.current = false; + } else if (!originScroll || lockRef.current) { + lockScroll(); + } + + return !lockRef.current && originScroll; + }, + [scrollPingRef, lockRef, localClearTimeout, lockScroll] + ); + + return lockScrollFn; +}; + +export default useLockScroll; diff --git a/src/hooks/useMobileTouchMove.ts b/src/hooks/useMobileTouchMove.ts index 7d3ff95d..a2f40148 100644 --- a/src/hooks/useMobileTouchMove.ts +++ b/src/hooks/useMobileTouchMove.ts @@ -1,44 +1,50 @@ -import * as React from 'react'; import { useRef } from 'react'; -import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; +import useLayoutEffect from 'rc-util/es/hooks/useLayoutEffect'; +import type { RefObject } from 'react'; const SMOOTH_PTG = 14 / 15; export default function useMobileTouchMove( + isHorizontalMode: boolean, inVirtual: boolean, - listRef: React.RefObject, - callback: (offsetY: number, smoothOffset?: boolean) => boolean, + listRef: RefObject, + callback: (offset: number, smoothOffset?: boolean) => boolean ) { const touchedRef = useRef(false); - const touchYRef = useRef(0); + const touchDeltaRef = useRef(0); - const elementRef = useRef(null); + const elementRef = useRef(); // Smooth scroll - const intervalRef = useRef(null); + const intervalRef = useRef>(); + const localClearInterval = (intervalHd?: ReturnType) => { + if (intervalHd) { + clearInterval(intervalHd); + } + }; /* eslint-disable prefer-const */ let cleanUpEvents: () => void; const onTouchMove = (e: TouchEvent) => { if (touchedRef.current) { - const currentY = Math.ceil(e.touches[0].pageY); - let offsetY = touchYRef.current - currentY; - touchYRef.current = currentY; + const currentDelta = Math.ceil(e.touches[0][isHorizontalMode ? 'pageX' : 'pageY']); + let offset = touchDeltaRef.current - currentDelta; + touchDeltaRef.current = currentDelta; - if (callback(offsetY)) { + if (callback(offset)) { e.preventDefault(); } // Smooth interval - clearInterval(intervalRef.current); + localClearInterval(intervalRef.current); intervalRef.current = setInterval(() => { - offsetY *= SMOOTH_PTG; + offset *= SMOOTH_PTG; - if (!callback(offsetY, true) || Math.abs(offsetY) <= 0.1) { - clearInterval(intervalRef.current); + if (!callback(offset, true) || Math.abs(offset) <= 0.1) { + localClearInterval(intervalRef.current); } - }, 16); + }, 16.67); } }; @@ -53,7 +59,7 @@ export default function useMobileTouchMove( if (e.touches.length === 1 && !touchedRef.current) { touchedRef.current = true; - touchYRef.current = Math.ceil(e.touches[0].pageY); + touchDeltaRef.current = Math.ceil(e.touches[0][isHorizontalMode ? 'pageX' : 'pageY']); elementRef.current = e.target as HTMLElement; elementRef.current.addEventListener('touchmove', onTouchMove); @@ -69,14 +75,16 @@ export default function useMobileTouchMove( }; useLayoutEffect(() => { - if (inVirtual) { + if (inVirtual && listRef.current) { listRef.current.addEventListener('touchstart', onTouchStart); } return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps listRef.current?.removeEventListener('touchstart', onTouchStart); cleanUpEvents(); - clearInterval(intervalRef.current); + localClearInterval(intervalRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [inVirtual]); } diff --git a/src/hooks/useOriginScroll.ts b/src/hooks/useOriginScroll.ts deleted file mode 100644 index 11bf5336..00000000 --- a/src/hooks/useOriginScroll.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useRef } from 'react'; - -export default (isScrollAtTop: boolean, isScrollAtBottom: boolean) => { - // Do lock for a wheel when scrolling - const lockRef = useRef(false); - const lockTimeoutRef = useRef(null); - function lockScroll() { - clearTimeout(lockTimeoutRef.current); - - lockRef.current = true; - - lockTimeoutRef.current = setTimeout(() => { - lockRef.current = false; - }, 50); - } - - // Pass to ref since global add is in closure - const scrollPingRef = useRef({ - top: isScrollAtTop, - bottom: isScrollAtBottom, - }); - scrollPingRef.current.top = isScrollAtTop; - scrollPingRef.current.bottom = isScrollAtBottom; - - return (deltaY: number, smoothOffset = false) => { - const originScroll = - // Pass origin wheel when on the top - (deltaY < 0 && scrollPingRef.current.top) || - // Pass origin wheel when on the bottom - (deltaY > 0 && scrollPingRef.current.bottom); - - if (smoothOffset && originScroll) { - // No need lock anymore when it's smooth offset from touchMove interval - clearTimeout(lockTimeoutRef.current); - lockRef.current = false; - } else if (!originScroll || lockRef.current) { - lockScroll(); - } - - return !lockRef.current && originScroll; - }; -}; diff --git a/src/hooks/useRenderData.ts b/src/hooks/useRenderData.ts new file mode 100644 index 00000000..7121b00a --- /dev/null +++ b/src/hooks/useRenderData.ts @@ -0,0 +1,147 @@ +import { useMemo } from 'react'; +import type { RefObject, Key } from 'react'; +import type { IGetKey } from '../types'; + +export interface IUseRenderDataParams { + isVirtualMode: boolean; + isEnableVirtual: boolean; + isHorizontalMode: boolean; + isStaticItem: boolean; + containerSize: number; + itemSize: number; + defaultRenderCount: number; + scrollOffset: number; + data: T[]; + updatedMark: number; + fillerInnerRef: RefObject; + getKey: IGetKey; + getRectSizeByKey: (key: Key) => number | undefined; +} + +/** + * Visible Calculation + * + * scrollSize: container max width or height + * startIndex the first item index to be rendered + * endIndex: the last item index to be rendered + * offset: the left or top of start item position + * */ + +const useRenderData = ({ + isVirtualMode, + isEnableVirtual, + isHorizontalMode, + isStaticItem, + containerSize: rawContainerSize, + itemSize, + defaultRenderCount, + scrollOffset, + data, + updatedMark, + fillerInnerRef, + getKey, + getRectSizeByKey, +}: IUseRenderDataParams) => { + return useMemo(() => { + if (!isEnableVirtual) { + return { + scrollSize: undefined, + startIndex: 0, + endIndex: data.length - 1, + offset: undefined, + }; + } + + // Always use virtual scroll bar in avoid shaking + if (!isVirtualMode) { + const _scrollSize = fillerInnerRef.current?.[isHorizontalMode ? 'offsetWidth' : 'offsetHeight'] || 0; + return { + scrollSize: _scrollSize, + startIndex: 0, + endIndex: data.length - 1, + offset: undefined, + }; + } + + const dataLen = data.length; + + let itemStart = 0; + let startIdx: number | undefined, firstChildOffset: number | undefined, endIdx: number | undefined; + + // optimization static item + const containerSize = rawContainerSize || 0; + if (isStaticItem && itemSize) { + startIdx = Math.max(Math.ceil(scrollOffset / itemSize) - 1, 0); + endIdx = Math.max(Math.ceil((scrollOffset + containerSize) / itemSize) - 1, 1); + firstChildOffset = itemSize * startIdx; + + return { + scrollSize: itemSize * dataLen, + startIndex: startIdx, + endIndex: endIdx, + offset: firstChildOffset, + }; + } + + for (let i = 0; i < dataLen; i += 1) { + const item = data[i]; + const key = getKey(item); + + const cacheSize = getRectSizeByKey(key); + const currentItemSize = (cacheSize ?? itemSize) || 0; + const currentItemEnd = itemStart + currentItemSize; + // Check if item in the viewport range and check if the first viewport item + if (currentItemEnd >= scrollOffset && startIdx === undefined) { + startIdx = i; + firstChildOffset = itemStart; + } + + // Check item end in the range. We will render additional one item for motion usage + const viewportEnd = scrollOffset + containerSize; + if (currentItemEnd > viewportEnd && endIdx === undefined) { + endIdx = i; + } + + itemStart = currentItemEnd; // update next item start position + } + + // When scrollOffset at the end but data cut to small count will reach this + if (startIdx === undefined) { + startIdx = 0; + firstChildOffset = 0; + + endIdx = defaultRenderCount; + } + + if (endIdx === undefined) { + endIdx = data.length - 1; + } + + // Give cache to improve scroll experience + endIdx = Math.min(endIdx + 1, data.length); + + return { + scrollSize: itemStart, + startIndex: startIdx, + endIndex: endIdx, + offset: firstChildOffset, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isVirtualMode, + isEnableVirtual, + isHorizontalMode, + isStaticItem, + rawContainerSize, + itemSize, + defaultRenderCount, + scrollOffset, + data, + updatedMark, + fillerInnerRef, + getKey, + getRectSizeByKey, + ]); +}; + +export default useRenderData; diff --git a/src/hooks/useScrollOffset.ts b/src/hooks/useScrollOffset.ts new file mode 100644 index 00000000..459c54ef --- /dev/null +++ b/src/hooks/useScrollOffset.ts @@ -0,0 +1,24 @@ +import { useCallback, useMemo, useState } from 'react'; +import useIsHorizontalMode from './useIsHorizontalMode'; +import type { IDirection } from '../types'; + +const useScrollOffset = (direction: IDirection) => { + const isHorizontalMode = useIsHorizontalMode(direction); + const [scrollTop, setScrollTop] = useState(0); // current scroll top + const [scrollLeft, setScrollLeft] = useState(0); // current scroll top + + const scrollOffset = useMemo(() => { + return isHorizontalMode ? scrollLeft : scrollTop; + }, [isHorizontalMode, scrollLeft, scrollTop]); + + const setScrollOffset = useCallback( + (offset: number | ((offset: number) => number)) => { + return isHorizontalMode ? setScrollLeft(offset) : setScrollTop(offset); + }, + [isHorizontalMode] + ); + + return [scrollOffset, setScrollOffset] as const; +}; + +export default useScrollOffset; diff --git a/src/hooks/useScrollTo.tsx b/src/hooks/useScrollTo.tsx index 02d8d32d..d440cbd2 100644 --- a/src/hooks/useScrollTo.tsx +++ b/src/hooks/useScrollTo.tsx @@ -1,115 +1,138 @@ -/* eslint-disable no-param-reassign */ -import * as React from 'react'; -import raf from 'rc-util/lib/raf'; -import type { ScrollTo } from '../List'; -import type { GetKey } from '../interface'; -import type CacheMap from '../utils/CacheMap'; +import raf from 'rc-util/es/raf'; +import { useCallback, useRef } from 'react'; +import type { Key, RefObject } from 'react'; +import type { IGetKey, IScrollTo, ITargetAlign } from '../types'; export default function useScrollTo( - containerRef: React.RefObject, + isHorizontalMode: boolean, + containerRef: RefObject, data: T[], - heights: CacheMap, - itemHeight: number, - getKey: GetKey, - collectHeight: () => void, - syncScrollTop: (newTop: number) => void, + getRectSizeByKey: (key: Key) => number | undefined, + itemSize: number, + getKey: IGetKey, + collectRectSize: () => void, + syncScrollOffset: (newOffset: number) => void, triggerFlash: () => void, -): ScrollTo { - const scrollRef = React.useRef(); - - return (arg) => { - // When not argument provided, we think dev may want to show the scrollbar - if (arg === null || arg === undefined) { - triggerFlash(); - return; - } - - // Normal scroll logic - raf.cancel(scrollRef.current); - - if (typeof arg === 'number') { - syncScrollTop(arg); - } else if (arg && typeof arg === 'object') { - let index: number; - const { align } = arg; - - if ('index' in arg) { - ({ index } = arg); - } else { - index = data.findIndex((item) => getKey(item) === arg.key); +): IScrollTo { + const scrollRef = useRef(); + + const scrollTo = useCallback( + (arg) => { + // When not argument provided, we think dev may want to show the scrollbar + if (arg === null || arg === undefined) { + triggerFlash(); + return; } - const { offset = 0 } = arg; + // Normal scroll logic + if (scrollRef.current) { + raf.cancel(scrollRef.current); + } + + if (typeof arg === 'number') { + syncScrollOffset(arg); + } else if (arg && typeof arg === 'object') { + let index: number; + const { align } = arg; - // We will retry 3 times in case dynamic height shaking - const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => { - if (times < 0 || !containerRef.current) return; + if ('index' in arg) { + ({ index } = arg); + } else { + index = data.findIndex((item) => getKey(item) === arg.key); + } - const height = containerRef.current.clientHeight; - let needCollectHeight = false; - let newTargetAlign: 'top' | 'bottom' | null = targetAlign; + const offset: number = arg?.offset || 0; - // Go to next frame if height not exist - if (height) { - const mergedAlign = targetAlign || align; + // We will retry 3 times in case dynamic height shaking + const syncScroll = (tryCount: number, targetAlign?: ITargetAlign) => { + if (tryCount < 0 || !containerRef.current) { + return; + } - // Get top & bottom - let stackTop = 0; - let itemTop = 0; - let itemBottom = 0; + const clientSize = + containerRef.current[isHorizontalMode ? 'clientWidth' : 'clientHeight']; + let needCollectSize = false; + let newTargetAlign: ITargetAlign | undefined = targetAlign; - const maxLen = Math.min(data.length, index); + // Go to next frame if height not exist + if (clientSize) { + const mergedAlign = targetAlign || align; - for (let i = 0; i <= maxLen; i += 1) { - const key = getKey(data[i]); - itemTop = stackTop; - const cacheHeight = heights.get(key); - itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); + // Get start & end + let stackStart = 0; + let itemStart = 0; + let itemEnd = 0; - stackTop = itemBottom; + const maxLen = Math.min(data.length, index); - if (i === index && cacheHeight === undefined) { - needCollectHeight = true; + for (let i = 0; i <= maxLen; i += 1) { + const key = getKey(data[i]); + itemStart = stackStart; + const cachedRectSize = getRectSizeByKey(key); + itemEnd = itemStart + (cachedRectSize === undefined ? itemSize : cachedRectSize); + + stackStart = itemEnd; + + if (i === index && cachedRectSize === undefined) { + needCollectSize = true; + } } - } - // Scroll to - let targetTop: number | null = null; - - switch (mergedAlign) { - case 'top': - targetTop = itemTop - offset; - break; - case 'bottom': - targetTop = itemBottom - height + offset; - break; - - default: { - const { scrollTop } = containerRef.current; - const scrollBottom = scrollTop + height; - if (itemTop < scrollTop) { - newTargetAlign = 'top'; - } else if (itemBottom > scrollBottom) { - newTargetAlign = 'bottom'; + // Scroll to + let targetStart: number | null = null; + + switch (mergedAlign) { + case 'start': + targetStart = itemStart - offset; + break; + case 'end': + targetStart = itemEnd - clientSize + offset; + break; + + default: { + const scrollStart = + containerRef.current[isHorizontalMode ? 'scrollLeft' : 'scrollTop']; + const scrollEnd = scrollStart + clientSize; + if (itemStart < scrollStart) { + newTargetAlign = 'start'; + } else if (itemEnd > scrollEnd) { + newTargetAlign = 'end'; + } } } - } - if (targetTop !== null && targetTop !== containerRef.current.scrollTop) { - syncScrollTop(targetTop); + if ( + targetStart !== null && + targetStart !== containerRef.current[isHorizontalMode ? 'scrollLeft' : 'scrollTop'] + ) { + syncScrollOffset(targetStart); + } } - } - // We will retry since element may not sync height as it described - scrollRef.current = raf(() => { - if (needCollectHeight) { - collectHeight(); - } - syncScroll(times - 1, newTargetAlign); - }, 2); // Delay 2 to wait for List collect heights - }; + // We will retry since element may not sync height as it described + scrollRef.current = raf(() => { + if (needCollectSize) { + collectRectSize(); + } + syncScroll(tryCount - 1, newTargetAlign); + }, 2); // Delay 2 to wait for List collect heights + }; - syncScroll(3); - } - }; + syncScroll(3); + } + }, + [ + isHorizontalMode, + containerRef, + data, + getRectSizeByKey, + itemSize, + getKey, + collectRectSize, + syncScrollOffset, + triggerFlash, + ], + ); + + return scrollTo; } diff --git a/src/hooks/useSyncScrollOffset.ts b/src/hooks/useSyncScrollOffset.ts new file mode 100644 index 00000000..29a619b9 --- /dev/null +++ b/src/hooks/useSyncScrollOffset.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import type { MutableRefObject } from 'react'; + +const useSyncScrollOffset = ( + isHorizontalMode: boolean, + componentRef: MutableRefObject, + keepInRange: (scrollOffset: number) => number, + setScrollOffset: any +) => { + return useCallback( + (newOffset: number | ((prev: number) => number)) => { + setScrollOffset((rawOffset: number) => { + const value = typeof newOffset === 'function' ? newOffset(rawOffset) : newOffset; + const alignedOffset = keepInRange(value); + + const field = isHorizontalMode ? 'scrollLeft' : 'scrollTop'; + if (componentRef.current) { + componentRef.current[field] = alignedOffset; + } + return alignedOffset; + }); + }, + [isHorizontalMode, componentRef, keepInRange, setScrollOffset] + ); +}; + +export default useSyncScrollOffset; diff --git a/src/index.ts b/src/index.ts index a312aa0d..c442ebbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import List from './List'; -export type { ListRef, ListProps } from './List'; +export { IDirection } from './types'; + +export type { IListRef, IListProps, IRenderFunc } from './types'; export default List; diff --git a/src/interface.ts b/src/interface.ts deleted file mode 100644 index e04d6737..00000000 --- a/src/interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type RenderFunc = ( - item: T, - index: number, - props: { style?: React.CSSProperties }, -) => React.ReactNode; - -export interface SharedConfig { - getKey: (item: T) => React.Key; -} - -export type GetKey = (item: T) => React.Key; diff --git a/src/mock.tsx b/src/mock.tsx deleted file mode 100644 index 716f821c..00000000 --- a/src/mock.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import type { ListProps, ListRef } from './List'; -import { RawList } from './List'; - -const List = React.forwardRef((props: ListProps, ref: React.Ref) => - RawList({ ...props, virtual: false }, ref), -) as ( - props: React.PropsWithChildren> & { ref?: React.Ref }, -) => React.ReactElement; - -(List as any).displayName = 'List'; - -export default List; diff --git a/src/mock/index.tsx b/src/mock/index.tsx new file mode 100644 index 00000000..4d7da8c5 --- /dev/null +++ b/src/mock/index.tsx @@ -0,0 +1,14 @@ +import { forwardRef } from 'react'; +import { RawList } from '../List'; +import type { PropsWithChildren, ReactElement, Ref } from 'react'; +import type { IListProps, IListRef } from '../types'; + +const List = forwardRef((props: IListProps, ref: Ref) => + RawList({ ...props, isEnableVirtual: false }, ref), +) as ( + props: PropsWithChildren> & { ref?: Ref }, +) => ReactElement; + +(List as any).displayName = 'List'; + +export default List; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..d1c6f005 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,74 @@ +import type { CSSProperties, Key, ReactNode, HTMLAttributes, FC, ComponentClass, UIEventHandler } from 'react'; +import type { IInnerProps } from './Filler'; + +export type IScrollAlign = 'top' | 'bottom' | 'auto'; +export type IScrollConfig = + | { + index: number; + align?: IScrollAlign; + offset?: number; + } + | { + key: Key; + align?: IScrollAlign; + offset?: number; + }; +export type IScrollTo = (arg: number | IScrollConfig) => void; +export type IListRef = { + scrollTo: IScrollTo; +}; + +export enum IDirection { + Horizontal = 1, + Vertical, +} + +export interface IListProps extends Omit, 'children'> { + prefixCls?: string; + children: IRenderFunc; + data: T[]; + containerSize?: number | string; // container rect size, height in vertical mode and width in horizontal mode. + itemSize?: number; // item min size, height in vertical mode and width in horizontal mode + isStaticItem?: boolean; // whether fixed width or height element + direction?: IDirection; // is horizontal list or vertical list , default vertical + isFullSize?: boolean; // If not match virtual scroll condition, Set List still use width or height of container. + itemKey: Key | ((item: T) => Key); + component?: string | FC | ComponentClass; + isEnableVirtual?: boolean; // Set `false` will always use real scroll instead of virtual one. + onScroll?: UIEventHandler; + onVisibleChange?: (visibleList: T[], fullList: T[]) => void; // Trigger when render list item changed + innerProps?: IInnerProps; // Inject to inner container props. Only use when you need pass aria related data +} + +export type IRenderFunc = (item: T, index: number, props: { style?: CSSProperties }) => ReactNode; + +export type IGetKey = (item: T) => Key; + +export interface IContext { + getKey: (item: T) => Key; +} + +export type ITargetAlign = 'start' | 'end'; + +export type ITimeoutHandher = ReturnType; + +export interface IUseComponentStyle { + isEnableVirtual?: boolean; + scrollMoving?: boolean; + isHorizontalMode?: boolean; + rawContainerSize?: number | string; + containerSize: number; + isFullSize?: boolean; +} + +export interface IUseIsEnableVirtualParams { + isEnableVirtual?: boolean; + containerSize?: number; + itemSize?: number; +} + +export interface FireFoxDOMMouseScrollEvent { + detail: number; + // eslint-disable-next-line @typescript-eslint/ban-types + preventDefault: Function; +} diff --git a/src/utils/CacheMap.ts b/src/utils/CacheMap.ts deleted file mode 100644 index 76bc465e..00000000 --- a/src/utils/CacheMap.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type React from 'react'; - -// Firefox has low performance of map. -class CacheMap { - maps: Record; - - constructor() { - this.maps = Object.create(null); - } - - set(key: React.ReactText, value: number) { - this.maps[key] = value; - } - - get(key: React.ReactText) { - return this.maps[key]; - } -} - -export default CacheMap; diff --git a/src/utils/algorithmUtil.ts b/src/utils/algorithmUtil.ts index 8e7a97e8..a7ceab30 100644 --- a/src/utils/algorithmUtil.ts +++ b/src/utils/algorithmUtil.ts @@ -1,4 +1,5 @@ -import type * as React from 'react'; +import type { Key } from 'react'; + /** * Get index with specific start index one by one. e.g. * min: 3, max: 9, start: 6 @@ -40,13 +41,12 @@ export function getIndexByStartLoc(min: number, max: number, start: number, inde export function findListDiffIndex( originList: T[], targetList: T[], - getKey: (item: T) => React.Key, + getKey: (item: T) => Key ): { index: number; multiple: boolean } | null { const originLen = originList.length; const targetLen = targetList.length; - let shortList: T[]; - let longList: T[]; + let shortList: T[], longList: T[]; if (originLen === 0 && targetLen === 0) { return null; @@ -69,7 +69,8 @@ export function findListDiffIndex( } // Loop to find diff one - let diffIndex: number = null; + const DefaultDiffIndex = -1; + let diffIndex: number = DefaultDiffIndex; let multiple = Math.abs(originLen - targetLen) !== 1; for (let i = 0; i < longList.length; i += 1) { const shortKey = getItemKey(shortList[i]); @@ -82,5 +83,5 @@ export function findListDiffIndex( } } - return diffIndex === null ? null : { index: diffIndex, multiple }; + return diffIndex === DefaultDiffIndex ? null : { index: diffIndex, multiple }; } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..96e341f9 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export { getIndexByStartLoc, findListDiffIndex } from './algorithmUtil'; +export { default as isFF } from './isFirefox';