Skip to content

Commit eb1a986

Browse files
committed
feat(ui): add virtual-scroll component
1 parent 130c0d1 commit eb1a986

14 files changed

Lines changed: 437 additions & 25 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"lodash": "^4.17.21",
3131
"react": "^17.0.2",
3232
"react-dom": "^17.0.2",
33-
"react-i18next": "^11.13.0",
33+
"react-i18next": "^11.14.2",
3434
"react-router-dom": "^6.0.0",
3535
"regenerator-runtime": "^0.13.9",
3636
"rfs": "^9.0.6",
@@ -88,11 +88,11 @@
8888
"rxjs-for-await": "^0.0.2",
8989
"sass": "^1.43.4",
9090
"standard-version": "^9.3.2",
91-
"stylelint": "^14.0.1",
91+
"stylelint": "^14.1.0",
9292
"stylelint-config-prettier": "^9.0.3",
9393
"stylelint-config-rational-order": "^0.1.2",
9494
"stylelint-config-recommended-scss": "^5.0.1",
95-
"stylelint-config-standard": "^23.0.0",
95+
"stylelint-config-standard": "^24.0.0",
9696
"stylelint-order": "^5.0.0",
9797
"stylelint-scss": "^4.0.0",
9898
"ts-jest": "^27.0.7",

packages/site/src/app/components/route/RouteArticle.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@
100100
color: var(--d-color-primary);
101101
text-decoration: none;
102102

103-
opacity: 0%;
103+
opacity: 0;
104104

105105
transition: opacity 0.2s linear;
106106

107107
&:focus-visible {
108108
outline: none;
109-
opacity: 100%;
109+
opacity: 1;
110110
}
111111
}
112112

@@ -117,7 +117,7 @@
117117
h5,
118118
h6 {
119119
&:hover .anchor {
120-
opacity: 100%;
120+
opacity: 1;
121121
}
122122
}
123123

packages/site/src/app/styles/_top-line-loader.scss

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
@mixin generate-linear-gradient($color) {
2-
background: linear-gradient(90deg, transparentize($color, 1) 0%, $color 30%, $color 50%, $color 70%, transparentize($color, 1) 100%);
2+
background: linear-gradient(
3+
90deg,
4+
color.adjust($color, $alpha: -1) 0%,
5+
$color 30%,
6+
$color 50%,
7+
$color 70%,
8+
color.adjust($color, $alpha: -1) 100%
9+
);
310
}
411

512
.app-top-line-loader {

packages/site/src/app/styles/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
@use 'sass:color';
12
@import 'variables';
23

34
@import 'app';

packages/ui/src/components/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ export { DMenu, DMenuGroup, DMenuItem, DMenuSub } from './menu';
1818

1919
export type { DTooltipProps } from './tooltip';
2020
export { DTooltip } from './tooltip';
21+
22+
export type { DVirtualScrollProps } from './virtual-scroll';
23+
export { DVirtualScroll } from './virtual-scroll';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
group: General
3+
title: VirtualScroll
4+
---
5+
6+
Virtual scrolling list.
7+
8+
## When To Use
9+
10+
When there are too many list entries, using virtual scrolling can greatly reduce the rendering burden.
11+
12+
## API
13+
14+
### DVirtualScrollProps\<T\>
15+
16+
You can pass all the `Props` supported by the corresponding element of `dTag`.
17+
18+
<!-- prettier-ignore-start -->
19+
| Property | Description | Type | Default |
20+
| --- | --- | --- | --- |
21+
| dTag | As the first parameter of `React.createElement` | 'string' | 'div' |
22+
| dWidth | List width, set this value to enable virtual scrolling in the horizontal direction | string \| number | - |
23+
| dHeight | List height, set this value to enable virtual scrolling in the vertical direction | string \| number | - |
24+
| dItemWidth | Manually set the width of list entries | number | - |
25+
| dItemHeight | Manually set the height of list items | number | - |
26+
| dList | List data | T[] | - |
27+
| dRenderItem | List item rendering | `(item: T, index: number) => React.ReactNode` | - |
28+
| dCustomSize | Enable multi-size mixing | `(item: T, index: number) => number` | - |
29+
| onScrollEnd | Callback function when scrolling to the bottom of the list | `() => void` | - |
30+
<!-- prettier-ignore-end -->
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
title: 虚拟滚动
3+
---
4+
5+
虚拟滚动列表。
6+
7+
## 何时使用
8+
9+
当列表条目过多时,使用虚拟滚动可以极大减少渲染负担。
10+
11+
## API
12+
13+
### DVirtualScrollProps\<T\>
14+
15+
可以传递 `dTag` 对应元素支持的所有 `Props`
16+
17+
<!-- prettier-ignore-start -->
18+
| 参数 | 说明 | 类型 | 默认值 |
19+
| --- | --- | --- | --- |
20+
| dTag | 作为 `React.createElement` 的第一个参数 | 'string' | 'div' |
21+
| dWidth | 列表宽度,设定该值启用水平方向的虚拟滚动 | string \| number | - |
22+
| dHeight | 列表高度,设定该值启用垂直方向的虚拟滚动 | string \| number | - |
23+
| dItemWidth | 手动设定列表条目宽度 | number | - |
24+
| dItemHeight | 手动设定列表条目高度 | number | - |
25+
| dList | 列表数据 | T[] | - |
26+
| dRenderItem | 列表条目渲染 | `(item: T, index: number) => React.ReactNode` | - |
27+
| dCustomSize | 启用多尺寸混合 | `(item: T, index: number) => number` | - |
28+
| onScrollEnd | 滚动到列表底部时的回调函数 | `() => void` | - |
29+
<!-- prettier-ignore-end -->
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { isUndefined } from 'lodash';
2+
import React, { useCallback, useEffect, useMemo } from 'react';
3+
import { useImmer } from 'use-immer';
4+
5+
import { useDComponentConfig, useElement, useId, useThrottle, useAsync } from '../../hooks';
6+
7+
export interface DVirtualScrollProps<T> {
8+
dTag?: string;
9+
dWidth?: string | number;
10+
dHeight?: string | number;
11+
dItemWidth?: number;
12+
dItemHeight?: number;
13+
dList: T[];
14+
dRenderItem: (item: T, index: number) => React.ReactNode;
15+
dCustomSize?: (item: T, index: number) => number;
16+
onScrollEnd?: () => void;
17+
[index: string]: unknown;
18+
}
19+
20+
export function DVirtualScroll<T>(props: DVirtualScrollProps<T>) {
21+
const {
22+
dTag = 'div',
23+
dWidth,
24+
dHeight,
25+
dItemWidth,
26+
dItemHeight,
27+
dList,
28+
dRenderItem,
29+
dCustomSize,
30+
onScrollEnd,
31+
style,
32+
onScroll,
33+
...restProps
34+
} = useDComponentConfig('virtual-scroll', props);
35+
36+
const { throttleByAnimationFrame } = useThrottle();
37+
const asyncCapture = useAsync();
38+
39+
//#region States.
40+
/*
41+
* @see https://reactjs.org/docs/state-and-lifecycle.html
42+
*
43+
* - Vue: data.
44+
* @see https://v3.vuejs.org/api/options-data.html#data-2
45+
* - Angular: property on a class.
46+
* @example
47+
* export class HeroChildComponent {
48+
* public data: 'example';
49+
* }
50+
*/
51+
const id = useId();
52+
53+
const [list, setList] = useImmer<React.ReactNode[]>([]);
54+
const [fillSize, setFillSize] = useImmer<[React.CSSProperties, React.CSSProperties]>([{}, {}]);
55+
//#endregion
56+
57+
//#region Element
58+
const el = useElement(`[data-d-virtual-scroll-${id}]`);
59+
const referenceEl = useElement(`[data-d-virtual-scroll-reference-${id}]`);
60+
//#endregion
61+
62+
//#region Getters.
63+
/*
64+
* When the dependency changes, recalculate the value.
65+
* In React, usually use `useMemo` to handle this situation.
66+
* Notice: `useCallback` also as getter that target at function.
67+
*
68+
* - Vue: computed.
69+
* @see https://v3.vuejs.org/guide/computed.html#computed-properties
70+
* - Angular: get property on a class.
71+
* @example
72+
* // ReactConvertService is a service that implement the
73+
* // methods when need to convert react to angular.
74+
* export class HeroChildComponent {
75+
* public get data():string {
76+
* return this.reactConvert.useMemo(factory, [deps]);
77+
* }
78+
*
79+
* constructor(private reactConvert: ReactConvertService) {}
80+
* }
81+
*/
82+
const itemSizeArr = useMemo(() => (dCustomSize ? dList.map((item, index) => dCustomSize(item, index)) : []), [dCustomSize, dList]);
83+
84+
const updateList = useCallback(() => {
85+
if (el.current) {
86+
const scrollSize = isUndefined(dWidth) ? el.current.scrollTop : el.current.scrollLeft;
87+
const rect = el.current.getBoundingClientRect();
88+
const size = isUndefined(dWidth) ? dItemHeight ?? rect.height : dItemWidth ?? rect.width;
89+
if (isUndefined(dCustomSize)) {
90+
if (referenceEl.current) {
91+
const itemSize = isUndefined(dWidth)
92+
? referenceEl.current.getBoundingClientRect().height
93+
: referenceEl.current.getBoundingClientRect().width;
94+
95+
if (size && itemSize) {
96+
const startIndex = Math.max(~~(scrollSize / itemSize) - 2, 1);
97+
const endIndex = Math.min(~~(scrollSize / itemSize) + ~~(size / itemSize) + 1 + 2, dList.length);
98+
setList(dList.slice(startIndex, endIndex).map((item, index) => dRenderItem(item, startIndex + index)));
99+
100+
setFillSize([
101+
{ [isUndefined(dWidth) ? 'height' : 'width']: Math.max(itemSize * (startIndex - 1), 0) },
102+
{ [isUndefined(dWidth) ? 'height' : 'width']: Math.max(itemSize * (dList.length - 1 - endIndex), 0) },
103+
]);
104+
if (scrollSize + size >= dList.length * itemSize) {
105+
onScrollEnd?.();
106+
}
107+
}
108+
}
109+
} else {
110+
if (size) {
111+
let accumulateSize = 0;
112+
let startInfo: [number, number] | undefined;
113+
let endInfo: [number, number] | undefined;
114+
for (const [index, itemSize] of itemSizeArr.entries()) {
115+
accumulateSize += itemSize;
116+
if (accumulateSize > scrollSize && isUndefined(startInfo)) {
117+
startInfo = [index, accumulateSize];
118+
}
119+
if (accumulateSize > scrollSize + size && isUndefined(endInfo)) {
120+
endInfo = [index, accumulateSize];
121+
}
122+
}
123+
124+
const startIndex = Math.max((startInfo?.[0] ?? 0) - 2, 0);
125+
const endIndex = Math.min((endInfo?.[0] ?? dList.length) + 1 + 2, dList.length);
126+
setList(dList.slice(startIndex, endIndex).map((item, index) => dRenderItem(item, startIndex + index)));
127+
128+
const preFillSize = Math.max(
129+
startInfo
130+
? (startInfo[1] ?? 0) -
131+
(itemSizeArr[startInfo[0]] ?? 0) -
132+
(itemSizeArr[startInfo[0] - 1] ?? 0) -
133+
(itemSizeArr[startInfo[0] - 2] ?? 0)
134+
: 0,
135+
0
136+
);
137+
const sufFillSize = Math.max(
138+
endInfo ? accumulateSize - endInfo[1] - (itemSizeArr[endInfo[0] + 1] ?? 0) - (itemSizeArr[endInfo[0] + 2] ?? 0) : 0,
139+
0
140+
);
141+
setFillSize([
142+
{ [isUndefined(dWidth) ? 'height' : 'width']: preFillSize },
143+
{ [isUndefined(dWidth) ? 'height' : 'width']: sufFillSize },
144+
]);
145+
if (scrollSize + size >= accumulateSize) {
146+
onScrollEnd?.();
147+
}
148+
}
149+
}
150+
}
151+
}, [el, dWidth, dItemHeight, dItemWidth, dCustomSize, referenceEl, dList, setList, setFillSize, onScrollEnd, dRenderItem, itemSizeArr]);
152+
153+
const reference = useMemo(() => {
154+
if (dList[0] && isUndefined(dCustomSize)) {
155+
const _reference = dRenderItem(dList[0], 0) as React.ReactElement;
156+
const [asyncGroup] = asyncCapture.createGroup('reference');
157+
asyncGroup.setTimeout(() => {
158+
if (referenceEl.current) {
159+
asyncGroup.onResize(referenceEl.current, () => throttleByAnimationFrame(updateList));
160+
}
161+
}, 20);
162+
return React.cloneElement(_reference, {
163+
..._reference.props,
164+
[`data-d-virtual-scroll-reference-${id}`]: 'true',
165+
});
166+
}
167+
}, [asyncCapture, dCustomSize, dList, dRenderItem, id, referenceEl, throttleByAnimationFrame, updateList]);
168+
169+
const handleScroll = useCallback(
170+
(e) => {
171+
(onScroll as React.UIEventHandler<HTMLElement>)?.(e);
172+
throttleByAnimationFrame(updateList);
173+
},
174+
[onScroll, throttleByAnimationFrame, updateList]
175+
);
176+
//#endregion
177+
178+
//#region DidUpdate.
179+
/*
180+
* We need a service(ReactConvertService) that implement useEffect.
181+
* @see https://reactjs.org/docs/hooks-effect.html
182+
*
183+
* - Vue: onUpdated.
184+
* @see https://v3.vuejs.org/api/composition-api.html#lifecycle-hooks
185+
* - Angular: ngDoCheck.
186+
* @see https://angular.io/api/core/DoCheck
187+
*/
188+
useEffect(() => {
189+
throttleByAnimationFrame(updateList);
190+
}, [throttleByAnimationFrame, updateList]);
191+
//#endregion
192+
193+
return React.createElement(
194+
dTag,
195+
{
196+
...restProps,
197+
style: {
198+
...(style as React.CSSProperties),
199+
width: dWidth,
200+
height: dHeight,
201+
whiteSpace: isUndefined(dWidth) ? undefined : 'nowrap',
202+
overflowX: isUndefined(dWidth) ? undefined : 'auto',
203+
overflowY: isUndefined(dHeight) ? undefined : 'auto',
204+
},
205+
[`data-d-virtual-scroll-${id}`]: 'true',
206+
onScroll: handleScroll,
207+
},
208+
[
209+
reference,
210+
<div
211+
key={`d-virtual-scroll-pre-fill-${id}`}
212+
style={{
213+
...fillSize[0],
214+
display: isUndefined(dWidth) ? undefined : 'inline-block',
215+
}}
216+
></div>,
217+
...list,
218+
<div
219+
key={`d-virtual-scroll-sub-fill-${id}`}
220+
style={{
221+
...fillSize[1],
222+
display: isUndefined(dWidth) ? undefined : 'inline-block',
223+
}}
224+
></div>,
225+
]
226+
);
227+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
title:
3+
en-US: Basic
4+
zh-Hant: 基本
5+
---
6+
7+
# en-US
8+
9+
The simplest usage.
10+
11+
# zh-Hant
12+
13+
最简单的用法。
14+
15+
```tsx
16+
import { DVirtualScroll } from '@react-devui/ui';
17+
18+
export default function Demo() {
19+
const list = Array(10000)
20+
.fill()
21+
.map((item, index) => 'Item ' + (index + 1));
22+
23+
return (
24+
<DVirtualScroll
25+
dHeight={200}
26+
dList={list}
27+
dRenderItem={(item) => (
28+
<div key={item} style={{ padding: '10px 0' }}>
29+
{item}
30+
</div>
31+
)}
32+
></DVirtualScroll>
33+
);
34+
}
35+
```

0 commit comments

Comments
 (0)