Skip to content

Commit 4240714

Browse files
authored
feat: introduce InfiniteScroll component (#2)
1 parent d99180f commit 4240714

File tree

14 files changed

+205
-1
lines changed

14 files changed

+205
-1
lines changed
File renamed without changes.

src/Component/__stories__/Component.stories.tsx renamed to src/components/Component/__stories__/Component.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ export default {
88
} as Meta;
99

1010
export const Playground: Story<ComponentProps> = (args) => <Component {...args} />;
11-
Playground.storyName = 'Component';
11+
Playground.storyName = 'Components/Component';
File renamed without changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
@use '../variables';
2+
3+
$block: '.#{variables.$ns}infinite-scroll';
4+
5+
#{$block} {
6+
&__loader {
7+
width: 100%;
8+
padding: 60px 0 20px;
9+
display: flex;
10+
justify-content: center;
11+
}
12+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, {useState, useRef, useEffect} from 'react';
2+
import {Loader} from '@gravity-ui/uikit';
3+
4+
import {block} from '../utils/cn';
5+
import {useIntersection} from './hooks/useIntersection';
6+
7+
import './InfiniteScroll.scss';
8+
9+
export interface InfiniteScrollProps {
10+
/** When called shows loader and wait till the promise is fulfilled to hide loader */
11+
onActivate: () => Promise<void>;
12+
/** Turn off activation */
13+
disabled: boolean;
14+
children: React.ReactNode;
15+
/** Custom loader */
16+
loader?: React.ReactNode;
17+
}
18+
19+
const b = block('infinite-scroll');
20+
21+
export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
22+
onActivate,
23+
disabled,
24+
children,
25+
loader,
26+
}) => {
27+
const [isActive, setIsActive] = useState(false);
28+
const [bottomRef, setBottomRef] = useState<HTMLDivElement | null>(null);
29+
const mounted = useRef(false);
30+
const isBottomVisible = useIntersection(bottomRef);
31+
32+
useEffect(() => {
33+
mounted.current = true;
34+
35+
return () => {
36+
mounted.current = false;
37+
};
38+
}, []);
39+
40+
useEffect(() => {
41+
const handleFetchData = async () => {
42+
setIsActive(true);
43+
await onActivate();
44+
45+
if (mounted.current) {
46+
setIsActive(false);
47+
}
48+
};
49+
50+
if (isBottomVisible && !isActive && !disabled) {
51+
handleFetchData();
52+
}
53+
}, [isBottomVisible, onActivate, isActive, disabled]);
54+
55+
const renderedLoader = loader || (
56+
<div className={b('loader')}>
57+
<Loader size="l" />
58+
</div>
59+
);
60+
61+
return (
62+
<>
63+
{children}
64+
65+
{isActive && renderedLoader}
66+
67+
<div className={b('intersector')} ref={setBottomRef} />
68+
</>
69+
);
70+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## InfiniteScroll
2+
3+
The component is useful for creating infinite lists like Social Network Feed, or events history. It renders its children. If you scroll it to the bottom, it will show a loader and call an `onActivate` callback. When all the data is loaded and there is no need to load more, pass the `disabled={true}` property.
4+
5+
### InfiniteScroll PropTypes
6+
7+
| Property | Type | Required | Default | Description |
8+
| :--------- | :-------------------- | :------: | :-------------------- | :----------------------------------------------------------------------------- |
9+
| onActivate | `() => Promise<void>` | `true` | | When called shows loader and wait till the promise is fulfilled to hide loader |
10+
| disabled | `Boolean` | `true` | | Turn off activation |
11+
| loader | `ReactNode` | `false` | `<Loader size="l" />` | Custom loader component |
12+
13+
### Example
14+
15+
```jsx
16+
const {data, fetchNextPage} = useFeedQuery();
17+
const isAllPostsShown = data.posts.length === data.total;
18+
19+
<InfiniteScroll onActivate={fetchNextPage} disabled={isAllPostsShown}>
20+
{data.posts.map((post) => (
21+
<Post key={post.id} post={post} />
22+
))}
23+
</InfiniteScroll>;
24+
```
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
import {InfiniteScroll} from '../InfiniteScroll';
3+
import {InfiniteScrollShowcase} from './InfiniteScrollShowcase';
4+
import type {Meta, Story} from '@storybook/react';
5+
6+
export default {
7+
title: 'Components/InfiniteScroll',
8+
component: InfiniteScroll,
9+
} as Meta;
10+
11+
export const Playground: Story = () => <InfiniteScrollShowcase />;
12+
Playground.storyName = 'InfiniteScroll';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from 'react';
2+
3+
import {InfiniteScroll} from '../InfiniteScroll';
4+
import {emailsList, delay} from './utils';
5+
6+
export function InfiniteScrollShowcase() {
7+
const [emails, setEmails] = React.useState<string[]>([]);
8+
const [page, setPage] = React.useState<number>(0);
9+
const isAllEmailsShown = emails.length >= emailsList.length;
10+
11+
const fetchData = async () => {
12+
await delay(3000);
13+
setPage(page + 1);
14+
setEmails(emailsList.slice(0, (page + 1) * 10));
15+
};
16+
17+
return (
18+
<InfiniteScroll onActivate={fetchData} disabled={isAllEmailsShown}>
19+
{emails.map((email, index) => (
20+
<li key={index} style={{lineHeight: '90px', fontSize: '54px'}}>
21+
{email}
22+
</li>
23+
))}
24+
</InfiniteScroll>
25+
);
26+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export function delay(ms: number): Promise<void> {
2+
return new Promise((resolve) => setTimeout(resolve, ms));
3+
}
4+
5+
export const emailsList = [
6+
'bdbrn@msn.com',
7+
'chlim@yahoo.ca',
8+
'tjensen@gmail.com',
9+
'morain@mac.com',
10+
'arachne@aol.com',
11+
'cfhsoft@att.net',
12+
'empathy@att.net',
13+
'goldberg@outlook.com',
14+
'jsnover@yahoo.ca',
15+
'carreras@gmail.com',
16+
'tubajon@att.net',
17+
'gbacon@gmail.com',
18+
'dialworld@verizon.net',
19+
'fglock@sbcglobal.net',
20+
'jane.smith@testfed.com',
21+
'terjesa@att.net',
22+
'tsuruta@optonline.net',
23+
'pkplex@yahoo.ca',
24+
'storerm@verizon.net',
25+
'overbom@aol.com',
26+
'rande@yahoo.ca',
27+
'crowemojo@msn.com',
28+
'tristan@yahoo.com',
29+
'ducasse@comcast.net',
30+
'tsuruta@mac.com',
31+
'kostas@mac.com',
32+
'rcwil@outlook.com',
33+
'msloan@aol.com',
34+
'sarahs@icloud.com',
35+
'srour@aol.com',
36+
'rnelson@me.com',
37+
'mcnihil@live.com',
38+
'treeves@me.com',
39+
];
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {useState, useEffect} from 'react';
2+
3+
export const useIntersection = (element: Element | null, options?: IntersectionObserverInit) => {
4+
const [isVisible, setState] = useState(false);
5+
6+
useEffect(() => {
7+
const observer = new IntersectionObserver(([entry]) => {
8+
setState(entry.isIntersecting);
9+
}, options);
10+
11+
if (element) {
12+
observer.observe(element);
13+
}
14+
15+
return () => (element === null ? undefined : observer.unobserve(element));
16+
}, [element, options]);
17+
18+
return isVisible;
19+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './InfiniteScroll';

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './AdaptiveTabs';
2+
export * from './InfiniteScroll';
23

34
export {Lang, configure} from './utils/configure';

0 commit comments

Comments
 (0)