1- import React , { useEffect , useMemo , useRef , useState } from "react" ;
1+ import React , { useEffect , useMemo , useReducer , useRef } from "react" ;
22import { Skeleton } from "../skeleton" ;
33import { Spinner } from "../spinner" ;
44import { Button } from "../button" ;
55
6- const defaultContentWrapper : React . FC < React . PropsWithChildren > = ( { children } ) => < > { children } </ > ;
6+ const DefaultContentWrapper : React . FC < React . PropsWithChildren > = ( { children } ) => children ;
77
88/**
99 * Props for the InfiniteScroll component that handles automatic loading of content
1010 * as the user scrolls or clicks a load more button.
1111 */
1212export interface InfiniteScrollProps {
13- /** Direction of infinite scroll - adapts the behaviour according to the scroll direction */
13+ /**
14+ * Direction of infinite scroll - adapts the behaviour according to the scroll direction
15+ * @default "bottom"
16+ */
1417 direction ?: "bottom" | "top" ;
1518 /** Number of items currently loaded - used to determine if more items are available */
1619 loaded ?: number ;
@@ -24,19 +27,30 @@ export interface InfiniteScrollProps {
2427 loadingMore : boolean ;
2528 /** Custom element to display while loading more content (defaults to Spinner) */
2629 loadingMoreElement ?: React . ReactNode ;
27- /** Whether to show a "Load more" button instead of automatic infinite scroll */
30+ /**
31+ * Whether to show a "Load more" button instead of automatic infinite scroll
32+ * @default true
33+ */
2834 displayLoadMore ?: boolean ;
2935 /** Function called when more content should be loaded */
3036 onLoadMore : ( ( ) => void ) | ( ( ) => Promise < void > ) ;
3137 /** Custom button element or render function for the load more button */
3238 loadMoreButton ?: React . ReactNode | ( ( props : { loadMore : ( ) => void } ) => React . ReactNode ) ;
3339 /** Custom wrapper component for the content area */
34- ContentWrapper ?: React . FC < React . PropsWithChildren > ;
40+ ContentWrapper ?: typeof DefaultContentWrapper ;
3541 /** Additional props passed to the intersection observer element */
3642 intersectionElementProps ?: React . ComponentPropsWithoutRef < "div" > ;
43+ /** Mode of the infinite scroll */
3744 mode ?: "infinite" | "enable-once" | "pagination" ;
3845}
3946
47+ interface InfiniteScrollStatus {
48+ /** Whether the infinite scroll is enabled */
49+ infiniteScrollEnabled : boolean ;
50+ /** Whether the intersection observer is intersecting */
51+ isIntersecting : boolean ;
52+ }
53+
4054export const InfiniteScroll : React . FC < React . PropsWithChildren < InfiniteScrollProps > > = ( {
4155 direction = "bottom" ,
4256 children,
@@ -49,26 +63,37 @@ export const InfiniteScroll: React.FC<React.PropsWithChildren<InfiniteScrollProp
4963 loadingMoreElement,
5064 onLoadMore,
5165 loadMoreButton = "Load more" ,
52- ContentWrapper = defaultContentWrapper ,
66+ ContentWrapper = DefaultContentWrapper ,
5367 intersectionElementProps,
5468 mode = loadMoreButton ? "enable-once" : "infinite" ,
5569} ) => {
70+ // Intersection ref
5671 const intersectionRef = useRef < HTMLDivElement > ( null ) ;
5772
58- const [ infiniteScrollEnabled , setInfiniteScrollEnabled ] = useState ( mode === "infinite" ) ;
59- const [ isIntersecting , setIsIntersecting ] = useState ( false ) ;
73+ // Status
74+ const [ { infiniteScrollEnabled, isIntersecting } , setStatus ] = useReducer (
75+ ( prev : InfiniteScrollStatus , next : Partial < InfiniteScrollStatus > ) => {
76+ return { ...prev , ...next } ;
77+ } ,
78+ {
79+ infiniteScrollEnabled : mode === "infinite" ,
80+ isIntersecting : false ,
81+ } ,
82+ ) ;
6083
84+ // Has items left
6185 const hasItemsLeft = useMemo (
6286 ( ) => ( loaded === undefined && total === undefined ) || ( loaded ?? 0 ) < ( total ?? 0 ) ,
6387 [ loaded , total ] ,
6488 ) ;
6589
90+ // Load more button
6691 const LoadMoreButton = useMemo ( ( ) => {
67- if ( ! displayLoadMore ) return null ;
92+ if ( ! displayLoadMore ) return ;
6893
6994 const action = ( ) => {
7095 if ( mode === "enable-once" ) {
71- setInfiniteScrollEnabled ( true ) ;
96+ setStatus ( { infiniteScrollEnabled : true } ) ;
7297 } else {
7398 if ( loading ) return ;
7499 void onLoadMore ( ) ;
@@ -88,45 +113,14 @@ export const InfiniteScroll: React.FC<React.PropsWithChildren<InfiniteScrollProp
88113 ) ;
89114 } , [ displayLoadMore , loadMoreButton , loading , mode , onLoadMore ] ) ;
90115
91- const elements = useMemo < React . ReactNode [ ] > ( ( ) => {
92- const elements = [
93- hasItemsLeft && loading ? ( loadingElement ?? < Skeleton /> ) : null ,
94- children ? (
95- < ContentWrapper >
96- { direction === "top" && < div ref = { intersectionRef } { ...intersectionElementProps } /> }
97- { children }
98- { direction === "bottom" && < div ref = { intersectionRef } { ...intersectionElementProps } /> }
99- </ ContentWrapper >
100- ) : null ,
101- hasItemsLeft && loadingMore ? ( loadingMoreElement ?? < Spinner /> ) : null ,
102- displayLoadMore && ! infiniteScrollEnabled && hasItemsLeft && ! loading && ! loadingMore
103- ? LoadMoreButton
104- : null ,
105- ] ;
106-
107- return direction === "bottom" ? elements : elements . reverse ( ) ;
108- } , [
109- hasItemsLeft ,
110- loading ,
111- loadingElement ,
112- children ,
113- ContentWrapper ,
114- intersectionRef ,
115- loadingMore ,
116- loadingMoreElement ,
117- displayLoadMore ,
118- infiniteScrollEnabled ,
119- LoadMoreButton ,
120- direction ,
121- intersectionElementProps ,
122- ] ) ;
123-
116+ // Intersection observer
124117 useEffect ( ( ) => {
125118 if ( ! intersectionRef . current || ! infiniteScrollEnabled || ! children || ! hasItemsLeft ) return ;
126119
120+ // eslint-disable-next-line compat/compat -- IntersectionObserver is not supported old browsers, but we target modern browsers
127121 const observer = new IntersectionObserver ( ( entries ) => {
128122 const isIntersecting = entries . some ( ( entry ) => entry . isIntersecting ) ;
129- setIsIntersecting ( isIntersecting ) ;
123+ setStatus ( { isIntersecting } ) ;
130124 } ) ;
131125
132126 observer . observe ( intersectionRef . current ) ;
@@ -136,12 +130,54 @@ export const InfiniteScroll: React.FC<React.PropsWithChildren<InfiniteScrollProp
136130 } ;
137131 } , [ children , hasItemsLeft , infiniteScrollEnabled ] ) ;
138132
133+ // Load more
139134 useEffect ( ( ) => {
140- if ( ! isIntersecting || ! infiniteScrollEnabled || loadingMore || loading || ! hasItemsLeft )
135+ if ( ! isIntersecting || ! infiniteScrollEnabled || loadingMore || loading || ! hasItemsLeft ) {
141136 return ;
137+ }
142138
139+ // Load more items
143140 void onLoadMore ( ) ;
144141 } , [ hasItemsLeft , infiniteScrollEnabled , isIntersecting , loading , loadingMore , onLoadMore ] ) ;
145142
146- return < > { elements } </ > ;
143+ // Load more controls
144+ const LoadMoreControls = useMemo ( ( ) => {
145+ if ( loadingMore ) {
146+ return loadingMoreElement ?? < Spinner /> ;
147+ }
148+
149+ return displayLoadMore && hasItemsLeft && ! infiniteScrollEnabled && LoadMoreButton ;
150+ } , [
151+ LoadMoreButton ,
152+ displayLoadMore ,
153+ hasItemsLeft ,
154+ infiniteScrollEnabled ,
155+ loadingMore ,
156+ loadingMoreElement ,
157+ ] ) ;
158+
159+ // Loading element
160+ if ( loading ) {
161+ return loadingElement ?? < Skeleton /> ;
162+ }
163+
164+ return (
165+ < ContentWrapper >
166+ { direction === "top" && (
167+ < >
168+ { LoadMoreControls }
169+ < div ref = { intersectionRef } { ...intersectionElementProps } />
170+ </ >
171+ ) }
172+
173+ { children }
174+
175+ { direction === "bottom" && (
176+ < >
177+ < div ref = { intersectionRef } { ...intersectionElementProps } />
178+ { LoadMoreControls }
179+ </ >
180+ ) }
181+ </ ContentWrapper >
182+ ) ;
147183} ;
0 commit comments