Skip to content

Commit 892a968

Browse files
committed
feat(infinite scroll): support infinite scroll up
1 parent b0f0d86 commit 892a968

File tree

3 files changed

+147
-76
lines changed

3 files changed

+147
-76
lines changed

packages/components/base/src/infinite-scroll/index.tsx

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,41 @@ import { Skeleton } from "../skeleton";
33
import { Spinner } from "../spinner";
44
import { Button } from "../button";
55

6-
const defaultContentWrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
7-
<div>{children}</div>
8-
);
9-
10-
export const InfiniteScroll: React.FC<
11-
React.PropsWithChildren<{
12-
readonly loaded?: number;
13-
readonly loading: boolean;
14-
readonly loadingElement?: React.ReactNode;
15-
readonly loadingMore: boolean;
16-
readonly loadingMoreElement?: React.ReactNode;
17-
readonly total?: number;
18-
readonly displayLoadMore?: boolean;
19-
readonly onLoadMore: () => void;
20-
readonly loadMoreButton?:
21-
| React.ReactNode
22-
| ((props: { loadMore: () => void }) => React.ReactNode);
23-
readonly ContentWrapper?: React.FC<React.PropsWithChildren>;
24-
}>
25-
> = ({
6+
const defaultContentWrapper: React.FC<React.PropsWithChildren> = ({ children }) => <>{children}</>;
7+
8+
/**
9+
* Props for the InfiniteScroll component that handles automatic loading of content
10+
* as the user scrolls or clicks a load more button.
11+
*/
12+
export interface InfiniteScrollProps {
13+
/** Direction of infinite scroll - adapts the behaviour according to the scroll direction */
14+
direction?: "bottom" | "top";
15+
/** Number of items currently loaded - used to determine if more items are available */
16+
loaded?: number;
17+
/** Total number of items available - used with loaded to determine remaining items */
18+
total?: number;
19+
/** Whether initial content is currently loading */
20+
loading: boolean;
21+
/** Custom element to display during initial loading (defaults to Skeleton) */
22+
loadingElement?: React.ReactNode;
23+
/** Whether additional content is currently being loaded */
24+
loadingMore: boolean;
25+
/** Custom element to display while loading more content (defaults to Spinner) */
26+
loadingMoreElement?: React.ReactNode;
27+
/** Whether to show a "Load more" button instead of automatic infinite scroll */
28+
displayLoadMore?: boolean;
29+
/** Function called when more content should be loaded */
30+
onLoadMore: (() => void) | (() => Promise<void>);
31+
/** Custom button element or render function for the load more button */
32+
loadMoreButton?: React.ReactNode | ((props: { loadMore: () => void }) => React.ReactNode);
33+
/** Custom wrapper component for the content area */
34+
ContentWrapper?: React.FC<React.PropsWithChildren>;
35+
/** Additional props passed to the intersection observer element */
36+
intersectionElementProps?: React.ComponentPropsWithoutRef<"div">;
37+
}
38+
39+
export const InfiniteScroll: React.FC<React.PropsWithChildren<InfiniteScrollProps>> = ({
40+
direction = "bottom",
2641
children,
2742
displayLoadMore = true,
2843
loaded,
@@ -34,6 +49,7 @@ export const InfiniteScroll: React.FC<
3449
onLoadMore,
3550
loadMoreButton = "Load more",
3651
ContentWrapper = defaultContentWrapper,
52+
intersectionElementProps,
3753
}) => {
3854
const intersectionRef = useRef<HTMLDivElement>(null);
3955

@@ -67,6 +83,37 @@ export const InfiniteScroll: React.FC<
6783
);
6884
}, [loadMoreButton, displayLoadMore]);
6985

86+
const elements = useMemo<React.ReactNode[]>(() => {
87+
const elements = [
88+
hasItemsLeft && loading ? (loadingElement ?? <Skeleton />) : null,
89+
children ? (
90+
<ContentWrapper>
91+
{direction === "top" && <div ref={intersectionRef} {...intersectionElementProps} />}
92+
{children}
93+
{direction === "bottom" && <div ref={intersectionRef} {...intersectionElementProps} />}
94+
</ContentWrapper>
95+
) : null,
96+
hasItemsLeft && loadingMore ? (loadingMoreElement ?? <Spinner />) : null,
97+
displayLoadMore && !infiniteScrollEnabled && hasItemsLeft ? LoadMoreButton : null,
98+
];
99+
100+
return direction === "bottom" ? elements : elements.reverse();
101+
}, [
102+
hasItemsLeft,
103+
loading,
104+
loadingElement,
105+
children,
106+
ContentWrapper,
107+
intersectionRef,
108+
loadingMore,
109+
loadingMoreElement,
110+
displayLoadMore,
111+
infiniteScrollEnabled,
112+
LoadMoreButton,
113+
direction,
114+
intersectionElementProps,
115+
]);
116+
70117
useEffect(() => {
71118
if (!intersectionRef.current || !infiniteScrollEnabled || !children || !hasItemsLeft) return;
72119

@@ -80,29 +127,14 @@ export const InfiniteScroll: React.FC<
80127
return () => {
81128
observer.disconnect();
82129
};
83-
}, [onLoadMore, infiniteScrollEnabled, children, hasItemsLeft]);
130+
}, [children, hasItemsLeft, infiniteScrollEnabled]);
84131

85132
useEffect(() => {
86133
if (!isIntersecting || !infiniteScrollEnabled || loadingMore || loading || !hasItemsLeft)
87134
return;
88135

89-
onLoadMore();
90-
}, [isIntersecting, onLoadMore, infiniteScrollEnabled, loadingMore, loading, hasItemsLeft]);
91-
92-
return (
93-
<>
94-
{hasItemsLeft && loading ? (loadingElement ?? <Skeleton />) : null}
136+
void onLoadMore();
137+
}, [hasItemsLeft, infiniteScrollEnabled, isIntersecting, loading, loadingMore, onLoadMore]);
95138

96-
{children ? (
97-
<ContentWrapper>
98-
{children}
99-
<div ref={intersectionRef} />
100-
</ContentWrapper>
101-
) : null}
102-
103-
{hasItemsLeft && loadingMore ? (loadingMoreElement ?? <Spinner />) : null}
104-
105-
{displayLoadMore && !infiniteScrollEnabled && hasItemsLeft ? LoadMoreButton : null}
106-
</>
107-
);
139+
return <>{elements}</>;
108140
};

packages/docs/stories/src/generic-infinite-scroll.stories.tsx

Lines changed: 75 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@
22
import React, { useState, useCallback, useEffect } from "react";
33
import { type Meta, type StoryObj } from "@storybook/react";
44
import { faker } from "@faker-js/faker";
5-
import { Text, InfiniteScroll, Flex, Card, Skeleton, Spinner, Manager } from "react-ck";
5+
import {
6+
Text,
7+
InfiniteScroll,
8+
Flex,
9+
Card,
10+
Skeleton,
11+
Spinner,
12+
Manager,
13+
VirtualizedList,
14+
VirtualizedListProps,
15+
} from "react-ck";
616
import { configureStory } from "@react-ck/storybook-utils";
717

18+
const VirtualizedListWrapper: VirtualizedListProps["Wrapper"] = ({ children }) => (
19+
<Flex direction="column" align="stretch" spacing="s">
20+
{children}
21+
</Flex>
22+
);
23+
824
const meta: Meta<typeof InfiniteScroll> = {
925
title: "Generic/InfiniteScroll",
1026
...configureStory(InfiniteScroll, {
@@ -25,21 +41,22 @@ type Story = StoryObj<typeof meta>;
2541
// Helper function to generate mock data
2642
const generateMockItems = (
2743
count: number,
44+
idFrom: number,
2845
): Array<{
2946
id: number;
3047
title: string;
3148
content: string;
3249
timestamp: string;
3350
}> =>
3451
Array.from({ length: count }, (_, index) => ({
35-
id: index + 1,
52+
id: idFrom + index + 1,
3653
title: faker.lorem.sentence(3),
3754
content: faker.lorem.paragraph(),
3855
timestamp: faker.date.recent().toLocaleDateString(),
3956
}));
4057

41-
const INITIAL_ITEMS = 3;
42-
const TOTAL_ITEMS = 15;
58+
const ITEMS_PER_PAGE = 3;
59+
const TOTAL_ITEMS = 31;
4360

4461
const customLoadingElement = (
4562
<>
@@ -62,56 +79,78 @@ export const Default: Story = {
6279
parameters: {
6380
layout: "padded",
6481
},
65-
render: () => {
66-
const [items, setItems] = useState(generateMockItems(0));
82+
args: {
83+
loadMoreButton: "Load more items",
84+
displayLoadMore: true,
85+
loadingMoreElement: customLoadingMoreElement,
86+
loadingElement: customLoadingElement,
87+
direction: "bottom",
88+
},
89+
render: (args) => {
90+
const [items, setItems] = useState<ReturnType<typeof generateMockItems>>([]);
6791
const [loading, setLoading] = useState(false);
6892
const [loadingMore, setLoadingMore] = useState(false);
6993

7094
const handleLoadMore = useCallback(() => {
7195
setLoadingMore(true);
7296

7397
setTimeout(() => {
74-
const newItems = generateMockItems(3);
75-
setItems((prev) => [...prev, ...newItems]);
98+
const newItems = generateMockItems(ITEMS_PER_PAGE, items.length);
99+
setItems(() =>
100+
args.direction === "bottom" ? [...items, ...newItems] : [...newItems.reverse(), ...items],
101+
);
76102
setLoadingMore(false);
77103
}, 1500);
78-
}, []);
104+
}, [args.direction, items]);
79105

106+
// load intially
80107
useEffect(() => {
81108
setLoading(true);
82109
setTimeout(() => {
83-
setItems(generateMockItems(INITIAL_ITEMS));
110+
const initialItems = generateMockItems(ITEMS_PER_PAGE, 0);
111+
setItems(() =>
112+
args.direction === "bottom" ? [...initialItems] : [...initialItems.reverse()],
113+
);
84114
setLoading(false);
85115
}, 1500);
86-
}, []);
116+
}, [args.direction]);
87117

88118
return (
89-
<Flex direction="column" align="stretch" spacing="s">
90-
<InfiniteScroll
91-
loaded={items.length}
92-
total={TOTAL_ITEMS}
93-
loading={loading}
94-
loadingMore={loadingMore}
95-
loadingElement={customLoadingElement}
96-
loadingMoreElement={customLoadingMoreElement}
97-
loadMoreButton="Load more items"
98-
displayLoadMore
99-
onLoadMore={handleLoadMore}>
100-
<Flex direction="column" align="stretch" spacing="s">
101-
{items.map((item) => (
102-
<Card key={item.id} skin="bordered">
103-
<Text skin="bold" margin="none">
104-
{item.title}
105-
</Text>
106-
<Text margin="top" variation="small">
107-
{item.content}
108-
</Text>
109-
</Card>
110-
))}
111-
</Flex>
112-
</InfiniteScroll>
113-
</Flex>
119+
<div style={{ maxHeight: "50vh", overflow: "auto" }}>
120+
<Flex direction="column" align="stretch" spacing="s">
121+
<InfiniteScroll
122+
{...args}
123+
loaded={items.length}
124+
total={TOTAL_ITEMS}
125+
loading={loading}
126+
loadingMore={loadingMore}
127+
onLoadMore={handleLoadMore}>
128+
<VirtualizedList
129+
defaultItemHeight={90}
130+
Wrapper={VirtualizedListWrapper}
131+
items={items.map((item) => (
132+
<Card key={item.id} skin="bordered">
133+
<Text skin="bold" margin="none">
134+
{item.id} - {item.title}
135+
</Text>
136+
<Text margin="top" variation="small">
137+
{item.content}
138+
</Text>
139+
</Card>
140+
))}
141+
/>
142+
</InfiniteScroll>
143+
</Flex>
144+
</div>
114145
);
115146
},
116147
};
117148
/* eslint-enable react-hooks/rules-of-hooks */
149+
150+
export const Reversed: Story = {
151+
...Default,
152+
args: {
153+
...Default.args,
154+
direction: "top",
155+
},
156+
};

packages/docs/storybook/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"build": "storybook build -o ./dist",
8-
"start": "storybook dev -p 6006",
8+
"start": "storybook dev -p 5050",
99
"lint:typescript": "tsc --noEmit"
1010
},
1111
"dependencies": {

0 commit comments

Comments
 (0)