oihana-next-ui 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oihana-next-ui",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "description": "Oihana Next.js UI component library — reusable components, hooks and utilities built with React 19, Next.js, Tailwind CSS and DaisyUI",
6
6
  "author": {
@@ -23,6 +23,7 @@ import { IoMdGrid as GridIcon } from "react-icons/io";
23
23
  import { FaPager as HeaderIcon } from "react-icons/fa6";
24
24
  import { SlPicture as ImageIcon } from "react-icons/sl";
25
25
  import { RiInputField as InputIcon } from "react-icons/ri" ;
26
+ import { TbInfinity as InfiniteScrollIcon } from "react-icons/tb";
26
27
  import { LuLayoutDashboard as LayoutIcon } from "react-icons/lu";
27
28
  import { CiBoxList as ListIcon } from "react-icons/ci";
28
29
  import { RiProgress7Line as LoadingIcon } from "react-icons/ri";
@@ -73,12 +74,13 @@ const navigation =
73
74
  Icon : LayoutIcon ,
74
75
  items :
75
76
  [
76
- { id : 'flex' , type : LINK , Icon : FlexIcon , path : '/lab/flex' } ,
77
- { id : 'grid' , type : LINK , Icon : GridIcon , path : '/lab/grid' } ,
78
- { id : 'masonry' , type : LINK , Icon : MasonryIcon , path : '/lab/masonry' } ,
79
- { id : 'layout' , type : LINK , Icon : LayoutIcon , path : '/lab/layout' } ,
80
- { id : 'collapse' , type : LINK , Icon : CollapseIcon , path : '/lab/collapse' } ,
81
- { id : 'patterns' , type : LINK , Icon : PatternsIcon , path : '/lab/patterns' } ,
77
+ { id : 'flex' , type : LINK , Icon : FlexIcon , path : '/lab/flex' } ,
78
+ { id : 'grid' , type : LINK , Icon : GridIcon , path : '/lab/grid' } ,
79
+ { id : 'masonry' , type : LINK , Icon : MasonryIcon , path : '/lab/masonry' } ,
80
+ { id : 'infiniteScroll' , type : LINK , Icon : InfiniteScrollIcon , path : '/lab/infiniteScroll' } ,
81
+ { id : 'layout' , type : LINK , Icon : LayoutIcon , path : '/lab/layout' } ,
82
+ { id : 'collapse' , type : LINK , Icon : CollapseIcon , path : '/lab/collapse' } ,
83
+ { id : 'patterns' , type : LINK , Icon : PatternsIcon , path : '/lab/patterns' } ,
82
84
  ]
83
85
  } ,
84
86
  {
@@ -25,6 +25,7 @@ const navigation =
25
25
  grid : 'Grid' ,
26
26
  layout : 'Layout' ,
27
27
  masonry : 'Masonry' ,
28
+ infiniteScroll : 'Scroll infini' ,
28
29
  table : 'Table' ,
29
30
  feedback : 'Feedback' ,
30
31
  loading : 'Loading' ,
@@ -73,6 +74,7 @@ const navigation =
73
74
  grid : 'Grid' ,
74
75
  layout : 'Layout' ,
75
76
  masonry : 'Masonry' ,
77
+ infiniteScroll : 'Infinite Scroll' ,
76
78
  table : 'Table' ,
77
79
  feedback : 'Feedback' ,
78
80
  loading : 'Loading' ,
@@ -0,0 +1,26 @@
1
+ 'use client' ;
2
+
3
+ import Page from '@/display/Page' ;
4
+ import Container from '@/display/Container' ;
5
+ import InfiniteScrollDemo from '@/demo/layouts/InfiniteScrollDemo' ;
6
+
7
+ /**
8
+ * InfiniteScroll showcase page.
9
+ */
10
+ const InfiniteScrollPage = () =>
11
+ (
12
+ <Page className="gap-8" full>
13
+ {/* Header */}
14
+ <Container className="text-center" maxWidth="max-w-4xl">
15
+ <h1 className="text-4xl font-bold bg-linear-to-r from-secondary to-primary inline-block text-transparent bg-clip-text">
16
+ Infinite Scroll
17
+ </h1>
18
+ </Container>
19
+
20
+ <Container maxWidth="max-w-3xl">
21
+ <InfiniteScrollDemo />
22
+ </Container>
23
+ </Page>
24
+ ) ;
25
+
26
+ export default InfiniteScrollPage ;
@@ -0,0 +1,172 @@
1
+ 'use client' ;
2
+
3
+ import { useRef } from 'react' ;
4
+
5
+ import Loading from '@/components/Loading' ;
6
+
7
+ import useInfiniteScroll from '@/hooks/useInfiniteScroll' ;
8
+
9
+ import cn from '@/themes/helpers/cn' ;
10
+
11
+ /**
12
+ * Default root element type for the InfiniteScroll container.
13
+ * @type {string}
14
+ */
15
+ export const DEFAULT_ELEMENT = 'div' ;
16
+
17
+ /**
18
+ * Class applied to the container when `scrollable` is enabled.
19
+ * @type {string}
20
+ */
21
+ export const SCROLLABLE_CLASS = 'overflow-y-auto' ;
22
+
23
+ /**
24
+ * Class applied to the (visually empty) sentinel element.
25
+ * @type {string}
26
+ */
27
+ export const SENTINEL_CLASS = 'h-px w-full' ;
28
+
29
+ /**
30
+ * Class applied to the default loading indicator wrapper.
31
+ * @type {string}
32
+ */
33
+ export const LOADER_WRAPPER_CLASS = 'flex justify-center py-4' ;
34
+
35
+ /**
36
+ * Class applied to the container in `reverse` mode (chat-like). Lays children
37
+ * out bottom-to-top so the scroll stays anchored at the bottom : prepending
38
+ * older items does not shift the visible content.
39
+ * @type {string}
40
+ */
41
+ export const REVERSE_CLASS = 'flex flex-col-reverse' ;
42
+
43
+ /**
44
+ * InfiniteScroll — renders its children, then watches a sentinel element to
45
+ * load more content when the user scrolls near the edge of the list.
46
+ *
47
+ * Headless by design : it does not render the list itself, it wraps whatever
48
+ * you pass as `children` and manages the sentinel plus the loading / end
49
+ * indicators. Loading is paused while `loading` is true and stops once
50
+ * `hasMore` becomes false.
51
+ *
52
+ * @module components/layouts/InfiniteScroll
53
+ *
54
+ * @param {Object} props
55
+ * @param {React.ElementType} [props.as='div'] - HTML element or component to render as the container.
56
+ * @param {React.ReactNode} [props.children] - The already-loaded content.
57
+ * @param {string} [props.className] - Additional class names for the container.
58
+ * @param {React.ReactNode} [props.endMessage=null] - Shown when `hasMore` is false.
59
+ * @param {boolean} [props.hasMore=true] - Whether more items can be loaded.
60
+ * @param {React.ReactNode} [props.loader] - Custom loading indicator (defaults to `<Loading />`).
61
+ * @param {boolean} [props.loading=false] - Loading-in-flight state.
62
+ * @param {Function} [props.onLoadMore] - Called when the sentinel is reached. Should be stable (`useCallback`).
63
+ * @param {boolean} [props.reverse=false] - Reverse / chat mode : lays children out bottom-to-top (`flex flex-col-reverse`) and loads older items when scrolling up. Children must be ordered newest-first.
64
+ * @param {string} [props.rootMargin] - Pre-load distance before the sentinel is reached.
65
+ * @param {boolean} [props.scrollable=false] - When true, the container is the scroll viewport and the observer root.
66
+ * @param {number} [props.threshold] - Visibility threshold (0-1).
67
+ *
68
+ * @example
69
+ * ```jsx
70
+ * // Forward infinite scroll over a List
71
+ * <InfiniteScroll
72
+ * hasMore={ hasMore }
73
+ * loading={ loading }
74
+ * onLoadMore={ loadNextPage }
75
+ * endMessage={ <p className="text-center opacity-60 py-4">No more items</p> }
76
+ * >
77
+ * <List>
78
+ * { items.map( item => <ListRow key={ item.id } title={ item.title } /> ) }
79
+ * </List>
80
+ * </InfiniteScroll>
81
+ *
82
+ * // Scrollable container with a fixed height (internal scroll viewport)
83
+ * <InfiniteScroll
84
+ * scrollable
85
+ * className="max-h-96"
86
+ * hasMore={ hasMore }
87
+ * loading={ loading }
88
+ * onLoadMore={ loadNextPage }
89
+ * >
90
+ * { rows }
91
+ * </InfiniteScroll>
92
+ *
93
+ * // Reverse / chat mode (older messages load when scrolling up).
94
+ * // `flex flex-col-reverse` is applied automatically ; pass messages newest-first.
95
+ * <InfiniteScroll
96
+ * reverse
97
+ * scrollable
98
+ * className="max-h-96"
99
+ * hasMore={ hasMore }
100
+ * loading={ loading }
101
+ * onLoadMore={ loadOlderMessages }
102
+ * >
103
+ * { messagesNewestFirst.map( m => <ChatBubble key={ m.id } { ...m } /> ) }
104
+ * </InfiniteScroll>
105
+ * ```
106
+ */
107
+ const InfiniteScroll =
108
+ ({
109
+ as ,
110
+ children ,
111
+ className ,
112
+ endMessage = null ,
113
+ hasMore = true ,
114
+ loader ,
115
+ loading = false ,
116
+ onLoadMore ,
117
+ reverse = false ,
118
+ rootMargin ,
119
+ scrollable = false ,
120
+ threshold ,
121
+ }) =>
122
+ {
123
+ const Component = as ?? DEFAULT_ELEMENT ;
124
+
125
+ /** @type {React.RefObject<HTMLElement>} - Container used as observer root when scrollable. */
126
+ const rootRef = useRef( null ) ;
127
+
128
+ const { ref : sentinelRef } = useInfiniteScroll
129
+ ({
130
+ hasMore ,
131
+ loading ,
132
+ onLoadMore ,
133
+ root : scrollable ? rootRef : null ,
134
+ rootMargin ,
135
+ threshold ,
136
+ }) ;
137
+
138
+ // --------- Building blocks
139
+
140
+ const sentinel = hasMore
141
+ ? <div ref={ sentinelRef } aria-hidden="true" className={ SENTINEL_CLASS } />
142
+ : null ;
143
+
144
+ const indicator = loading
145
+ ? ( loader ?? <div className={ LOADER_WRAPPER_CLASS }><Loading /></div> )
146
+ : null ;
147
+
148
+ const footer = !hasMore ? endMessage : null ;
149
+
150
+ // --------- Render container
151
+ //
152
+ // DOM order is always [ children , indicator , sentinel , footer ].
153
+ // In `reverse` mode the container is laid out bottom-to-top (flex-col-reverse),
154
+ // so the sentinel / indicator / footer naturally end up at the visual top and
155
+ // the scroll stays anchored at the bottom.
156
+
157
+ return (
158
+ <Component
159
+ className = { cn( scrollable && SCROLLABLE_CLASS , reverse && REVERSE_CLASS , className ) }
160
+ ref = { rootRef }
161
+ >
162
+ { children }
163
+ { indicator }
164
+ { sentinel }
165
+ { footer }
166
+ </Component>
167
+ ) ;
168
+ } ;
169
+
170
+ InfiniteScroll.displayName = 'InfiniteScroll' ;
171
+
172
+ export default InfiniteScroll ;
@@ -0,0 +1,343 @@
1
+ 'use client' ;
2
+
3
+ import { useCallback , useEffect , useState } from 'react' ;
4
+
5
+ import InfiniteScroll from '@/components/layouts/InfiniteScroll' ;
6
+ import List from '@/components/lists/List' ;
7
+ import ListRow from '@/components/lists/ListRow' ;
8
+
9
+ /**
10
+ * Number of items appended on each load.
11
+ * @type {number}
12
+ */
13
+ const PAGE_SIZE = 15 ;
14
+
15
+ /**
16
+ * Total number of items available in the simulated dataset.
17
+ * @type {number}
18
+ */
19
+ const TOTAL_ITEMS = 75 ;
20
+
21
+ /**
22
+ * Simulated network latency, in milliseconds.
23
+ * @type {number}
24
+ */
25
+ const LATENCY = 700 ;
26
+
27
+ /**
28
+ * Simulates a paginated API call returning a slice of items.
29
+ *
30
+ * @param {number} page - Zero-based page index to fetch.
31
+ * @returns {Promise<{ items: Array<{ id: number, title: string, subtitle: string }>, hasMore: boolean }>}
32
+ */
33
+ const fetchPage = ( page ) => new Promise( resolve =>
34
+ {
35
+ setTimeout( () =>
36
+ {
37
+ const start = page * PAGE_SIZE ;
38
+ const end = Math.min( start + PAGE_SIZE , TOTAL_ITEMS ) ;
39
+
40
+ const items = [] ;
41
+
42
+ for ( let i = start ; i < end ; i++ )
43
+ {
44
+ items.push
45
+ ({
46
+ id : i + 1 ,
47
+ title : `Item #${ i + 1 }` ,
48
+ subtitle : `Loaded on page ${ page + 1 }` ,
49
+ }) ;
50
+ }
51
+
52
+ resolve({ items , hasMore : end < TOTAL_ITEMS }) ;
53
+ }
54
+ , LATENCY ) ;
55
+ }) ;
56
+
57
+ // ============================================================================
58
+ // Reverse / chat demo
59
+ // ============================================================================
60
+
61
+ /**
62
+ * Number of messages loaded on each "load older" request.
63
+ * @type {number}
64
+ */
65
+ const CHAT_PAGE_SIZE = 8 ;
66
+
67
+ /**
68
+ * Side of the conversation a speaker sits on.
69
+ */
70
+ const START = 'start' ;
71
+ const END = 'end' ;
72
+
73
+ /**
74
+ * Avatars used by the two speakers.
75
+ */
76
+ const KENOBI_AVATAR = 'https://img.daisyui.com/images/profile/demo/kenobee@192.webp' ;
77
+ const ANAKIN_AVATAR = 'https://img.daisyui.com/images/profile/demo/anakeen@192.webp' ;
78
+
79
+ /**
80
+ * Bubble color (DaisyUI) used for the "current user" (Anakin).
81
+ * @type {string}
82
+ */
83
+ const OWN_BUBBLE_COLOR = 'chat-bubble-primary' ;
84
+
85
+ /**
86
+ * The conversation, ordered oldest → newest. Each line alternates between
87
+ * Obi-Wan (chat-start) and Anakin (chat-end).
88
+ */
89
+ const SCRIPT =
90
+ [
91
+ [ 'obiwan' , 'It was said that you would destroy the Sith, not join them.' ] ,
92
+ [ 'anakin' , "Don't lecture me, Obi-Wan." ] ,
93
+ [ 'obiwan' , 'It was you who would bring balance to the Force…' ] ,
94
+ [ 'anakin' , 'I should have known the Jedi were plotting to take over.' ] ,
95
+ [ 'obiwan' , '…not leave it in Darkness.' ] ,
96
+ [ 'anakin' , "If you're not with me, then you're my enemy." ] ,
97
+ [ 'obiwan' , 'Only a Sith deals in absolutes.' ] ,
98
+ [ 'anakin' , 'You will try.' ] ,
99
+ [ 'obiwan' , 'You were the Chosen One!' ] ,
100
+ [ 'anakin' , 'I hate you!' ] ,
101
+ [ 'obiwan' , 'You were my brother, Anakin.' ] ,
102
+ [ 'obiwan' , 'I loved you.' ] ,
103
+ [ 'anakin' , 'What kind of nonsense is this?' ] ,
104
+ [ 'anakin' , 'Put me on the Council and not make me a Master!?' ] ,
105
+ [ 'obiwan' , 'Anakin, calm down.' ] ,
106
+ [ 'anakin' , "That's never been done in the history of the Jedi." ] ,
107
+ [ 'anakin' , "It's insulting!" ] ,
108
+ [ 'obiwan' , 'You have been given a great honor.' ] ,
109
+ [ 'obiwan' , 'To be on the Council at your age…' ] ,
110
+ [ 'obiwan' , "It's never happened before." ] ,
111
+ [ 'anakin' , 'The Council wants me to spy on the Chancellor?' ] ,
112
+ [ 'obiwan' , 'The Council is asking you.' ] ,
113
+ [ 'anakin' , 'This is treason.' ] ,
114
+ [ 'obiwan' , 'Be mindful of your thoughts, Anakin.' ] ,
115
+ [ 'anakin' , 'I have brought peace, freedom, justice…' ] ,
116
+ [ 'obiwan' , 'Your new empire?!' ] ,
117
+ [ 'anakin' , "Don't make me kill you." ] ,
118
+ [ 'obiwan' , 'I will do what I must.' ] ,
119
+ ] ;
120
+
121
+ /**
122
+ * Builds the full conversation as message objects, oldest → newest.
123
+ *
124
+ * @returns {Array<{ id: number, side: string, author: string, avatar: string, color: string, text: string, time: string }>}
125
+ */
126
+ const buildConversation = () => SCRIPT.map( ( [ speaker , text ] , index ) =>
127
+ {
128
+ const isObiwan = speaker === 'obiwan' ;
129
+
130
+ const hours = 12 + Math.floor( index / 60 ) ;
131
+ const minutes = index % 60 ;
132
+
133
+ return {
134
+ id : index + 1 ,
135
+ side : isObiwan ? START : END ,
136
+ author : isObiwan ? 'Obi-Wan Kenobi' : 'Anakin' ,
137
+ avatar : isObiwan ? KENOBI_AVATAR : ANAKIN_AVATAR ,
138
+ color : isObiwan ? '' : OWN_BUBBLE_COLOR ,
139
+ text ,
140
+ time : `${ hours }:${ String( minutes ).padStart( 2 , '0' ) }` ,
141
+ } ;
142
+ } ) ;
143
+
144
+ /**
145
+ * Full conversation, computed once.
146
+ */
147
+ const CONVERSATION = buildConversation() ;
148
+
149
+ /**
150
+ * A single chat message, rendered with DaisyUI chat bubble classes.
151
+ *
152
+ * @param {Object} props
153
+ * @param {string} props.author - Author name shown in the header.
154
+ * @param {string} props.avatar - Author image URL.
155
+ * @param {string} props.color - DaisyUI chat-bubble color class.
156
+ * @param {string} props.side - 'start' or 'end'.
157
+ * @param {string} props.text - Message body.
158
+ * @param {string} props.time - Timestamp shown in the header.
159
+ */
160
+ const ChatMessage =
161
+ ({
162
+ author ,
163
+ avatar ,
164
+ color ,
165
+ side ,
166
+ text ,
167
+ time ,
168
+ }) =>
169
+ (
170
+ <div className={ `chat ${ side === END ? 'chat-end' : 'chat-start' }` }>
171
+ <div className="chat-image avatar">
172
+ <div className="w-10 rounded-full">
173
+ <img alt={ author } src={ avatar } />
174
+ </div>
175
+ </div>
176
+ <div className="chat-header text-xs">
177
+ { author }
178
+ <time className="opacity-50 ml-1">{ time }</time>
179
+ </div>
180
+ <div className={ `chat-bubble ${ color }` }>{ text }</div>
181
+ </div>
182
+ ) ;
183
+
184
+ /**
185
+ * Chat panel : a reverse infinite scroll that loads older messages when the
186
+ * user scrolls up. Messages are kept oldest → newest in state and rendered
187
+ * newest-first, as required by `<InfiniteScroll reverse>`.
188
+ */
189
+ const ChatPanel = () =>
190
+ {
191
+ const [ loaded , setLoaded ] = useState( [] ) ;
192
+ const [ loading , setLoading ] = useState( false ) ;
193
+ const [ hasMore , setHasMore ] = useState( true ) ;
194
+
195
+ // --------- Prepend the previous (older) page of messages
196
+
197
+ const loadOlder = useCallback( async () =>
198
+ {
199
+ if ( loading || !hasMore )
200
+ {
201
+ return ;
202
+ }
203
+
204
+ setLoading( true ) ;
205
+
206
+ await new Promise( resolve => setTimeout( resolve , LATENCY ) ) ;
207
+
208
+ const remaining = CONVERSATION.length - loaded.length ;
209
+ const start = Math.max( 0 , remaining - CHAT_PAGE_SIZE ) ;
210
+ const older = CONVERSATION.slice( start , remaining ) ;
211
+
212
+ setLoaded( prev => [ ...older , ...prev ] ) ;
213
+ setHasMore( start > 0 ) ;
214
+ setLoading( false ) ;
215
+ }
216
+ , [ hasMore , loaded.length , loading ] ) ;
217
+
218
+ // --------- Initial load (most recent page)
219
+
220
+ useEffect( () =>
221
+ {
222
+ loadOlder() ;
223
+ // eslint-disable-next-line react-hooks/exhaustive-deps
224
+ } , [] ) ;
225
+
226
+ return (
227
+ <InfiniteScroll
228
+ className = "max-h-96 p-4 gap-1 rounded-box border border-base-300 bg-base-100"
229
+ endMessage = {
230
+ <p className="text-center text-xs opacity-60 py-2">
231
+ — Beginning of the conversation —
232
+ </p>
233
+ }
234
+ hasMore = { hasMore }
235
+ loading = { loading }
236
+ onLoadMore = { loadOlder }
237
+ reverse
238
+ scrollable
239
+ >
240
+ { [ ...loaded ].reverse().map( message => (
241
+ <ChatMessage key={ message.id } { ...message } />
242
+ ) ) }
243
+ </InfiniteScroll>
244
+ ) ;
245
+ } ;
246
+
247
+ /**
248
+ * InfiniteScroll showcase : a forward scrollable list and a reverse chat panel,
249
+ * both lazily loading paginated content.
250
+ */
251
+ const InfiniteScrollDemo = () =>
252
+ {
253
+ const [ items , setItems ] = useState( [] ) ;
254
+ const [ page , setPage ] = useState( 0 ) ;
255
+ const [ loading , setLoading ] = useState( false ) ;
256
+ const [ hasMore , setHasMore ] = useState( true ) ;
257
+
258
+ // --------- Append the next page, guarding against concurrent loads
259
+
260
+ const loadMore = useCallback( async () =>
261
+ {
262
+ if ( loading || !hasMore )
263
+ {
264
+ return ;
265
+ }
266
+
267
+ setLoading( true ) ;
268
+
269
+ const { items : newItems , hasMore : more } = await fetchPage( page ) ;
270
+
271
+ setItems( prev => [ ...prev , ...newItems ] ) ;
272
+ setHasMore( more ) ;
273
+ setPage( prev => prev + 1 ) ;
274
+ setLoading( false ) ;
275
+ }
276
+ , [ hasMore , loading , page ] ) ;
277
+
278
+ // --------- Initial load
279
+
280
+ useEffect( () =>
281
+ {
282
+ loadMore() ;
283
+ // eslint-disable-next-line react-hooks/exhaustive-deps
284
+ } , [] ) ;
285
+
286
+ return (
287
+ <div className="flex flex-col gap-8">
288
+
289
+ {/* Forward scroll */}
290
+ <div className="card bg-base-200 shadow-xl">
291
+ <div className="card-body">
292
+
293
+ <h2 className="card-title text-2xl mb-2">Scrollable container</h2>
294
+ <p className="text-sm opacity-70 mb-4">
295
+ { items.length } / { TOTAL_ITEMS } items loaded — scroll down to load more.
296
+ </p>
297
+
298
+ <InfiniteScroll
299
+ className = "max-h-96 py-2 rounded-box border border-base-300 bg-base-100"
300
+ endMessage = {
301
+ <p className="text-center text-sm opacity-60 py-4">
302
+ — End of the list —
303
+ </p>
304
+ }
305
+ hasMore = { hasMore }
306
+ loading = { loading }
307
+ onLoadMore = { loadMore }
308
+ scrollable
309
+ >
310
+ <List>
311
+ { items.map( item => (
312
+ <ListRow
313
+ key = { item.id }
314
+ title = { item.title }
315
+ subtitle = { item.subtitle }
316
+ />
317
+ ) ) }
318
+ </List>
319
+ </InfiniteScroll>
320
+
321
+ </div>
322
+ </div>
323
+
324
+ {/* Reverse / chat */}
325
+ <div className="card bg-base-200 shadow-xl">
326
+ <div className="card-body">
327
+
328
+ <h2 className="card-title text-2xl mb-2">Reverse mode — chat</h2>
329
+ <p className="text-sm opacity-70 mb-4">
330
+ Scroll up to load older messages. New messages stay pinned at the
331
+ bottom and the scroll position never jumps.
332
+ </p>
333
+
334
+ <ChatPanel />
335
+
336
+ </div>
337
+ </div>
338
+
339
+ </div>
340
+ ) ;
341
+ } ;
342
+
343
+ export default InfiniteScrollDemo ;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Resolves a DOM element from either a React ref object or a raw element.
3
+ *
4
+ * Useful when an API (IntersectionObserver, ResizeObserver, …) expects a DOM
5
+ * element but the caller may provide either a ref or the element directly.
6
+ *
7
+ * @param {React.RefObject<Element>|Element|null} [target] - Ref object, element or null.
8
+ * @returns {Element|null} The resolved DOM element, or null.
9
+ *
10
+ * @example
11
+ * ```js
12
+ * resolveRefElement( myRef ) ; // → myRef.current
13
+ * resolveRefElement( domNode ) ; // → domNode
14
+ * resolveRefElement( null ) ; // → null
15
+ * ```
16
+ */
17
+ const resolveRefElement = ( target ) =>
18
+ {
19
+ if ( target && typeof target === 'object' && 'current' in target )
20
+ {
21
+ return target.current ;
22
+ }
23
+ return target ?? null ;
24
+ } ;
25
+
26
+ export default resolveRefElement ;
@@ -0,0 +1,100 @@
1
+ 'use client' ;
2
+
3
+ import { useEffect , useRef } from 'react' ;
4
+
5
+ import resolveRefElement from '@/helpers/react/resolveRefElement' ;
6
+
7
+ /**
8
+ * Default distance, before the sentinel is reached, at which loading is triggered.
9
+ * @type {string}
10
+ */
11
+ export const DEFAULT_ROOT_MARGIN = '200px' ;
12
+
13
+ /**
14
+ * Default visibility threshold passed to the IntersectionObserver.
15
+ * @type {number}
16
+ */
17
+ export const DEFAULT_THRESHOLD = 0 ;
18
+
19
+ /**
20
+ * Hook that invokes a callback when a sentinel element scrolls into view.
21
+ *
22
+ * Building block for infinite scrolling / "load more on scroll". Attach the
23
+ * returned `ref` to a sentinel element placed at the edge of your list ; when
24
+ * that sentinel enters the viewport (or the scrollable `root`), `onLoadMore`
25
+ * is called. Observation is paused while `loading` is true and stops once
26
+ * `hasMore` becomes false.
27
+ *
28
+ * Note : `onLoadMore` should be stable (wrap it in `useCallback`), otherwise
29
+ * the observer is recreated on every render.
30
+ *
31
+ * @param {Object} [options]
32
+ * @param {boolean} [options.hasMore=true] - Whether more items can be loaded.
33
+ * @param {boolean} [options.loading=false] - Pauses observation while a load is in flight.
34
+ * @param {Function} [options.onLoadMore] - Called when the sentinel becomes visible.
35
+ * @param {React.RefObject<Element>|Element|null} [options.root=null] - Scrollable container used as observer root ; defaults to the viewport.
36
+ * @param {string} [options.rootMargin=DEFAULT_ROOT_MARGIN]- Pre-load distance before the sentinel is reached.
37
+ * @param {number} [options.threshold=DEFAULT_THRESHOLD] - Visibility threshold (0-1).
38
+ *
39
+ * @returns {{ ref: React.RefObject<HTMLElement> }} Ref to attach to the sentinel element.
40
+ *
41
+ * @example
42
+ * ```js
43
+ * const onLoadMore = useCallback( () => loadNextPage() , [ loadNextPage ] ) ;
44
+ * const { ref } = useInfiniteScroll({ hasMore , loading , onLoadMore }) ;
45
+ *
46
+ * <ul>
47
+ * { items.map( item => <li key={ item.id }>{ item.label }</li> ) }
48
+ * <li ref={ ref } aria-hidden="true" />
49
+ * </ul>
50
+ * ```
51
+ */
52
+ const useInfiniteScroll =
53
+ ({
54
+ hasMore = true ,
55
+ loading = false ,
56
+ onLoadMore ,
57
+ root = null ,
58
+ rootMargin = DEFAULT_ROOT_MARGIN ,
59
+ threshold = DEFAULT_THRESHOLD ,
60
+ }
61
+ = {}) =>
62
+ {
63
+ /** @type {React.RefObject<HTMLElement>} */
64
+ const ref = useRef( null ) ;
65
+
66
+ useEffect( () =>
67
+ {
68
+ const element = ref.current ;
69
+
70
+ if ( !element || !hasMore || loading )
71
+ {
72
+ return ;
73
+ }
74
+
75
+ const observer = new IntersectionObserver
76
+ (
77
+ ( [ entry ] ) =>
78
+ {
79
+ if ( entry.isIntersecting )
80
+ {
81
+ onLoadMore?.() ;
82
+ }
83
+ } ,
84
+ {
85
+ root : resolveRefElement( root ) ,
86
+ rootMargin ,
87
+ threshold ,
88
+ }
89
+ ) ;
90
+
91
+ observer.observe( element ) ;
92
+
93
+ return () => observer.disconnect() ;
94
+ }
95
+ , [ hasMore , loading , onLoadMore , root , rootMargin , threshold ] ) ;
96
+
97
+ return { ref } ;
98
+ } ;
99
+
100
+ export default useInfiniteScroll ;
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
- const version = "0.2.1" ;
1
+ const version = "0.2.2" ;
2
2
 
3
3
  export default version ;