Skip to content

Commit ccdb3fa

Browse files
committed
feat: added a scroll pagination hook / component (to refactor).
1 parent 884d2f5 commit ccdb3fa

4 files changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useIntersection } from "bagon-hooks"
2+
import {
3+
type Accessor,
4+
createEffect,
5+
createMemo,
6+
createSignal,
7+
Match,
8+
on,
9+
Show,
10+
Switch,
11+
type VoidProps,
12+
} from "solid-js"
13+
import { IconLoading } from "@/assets/icons"
14+
import { cn } from "@/utils/cn"
15+
16+
// ===========================================================================
17+
// HOOK
18+
// ===========================================================================
19+
interface UseScrollPaginationOptions {
20+
onLoadMore: () => void
21+
isLoading: boolean
22+
hasMore: boolean
23+
threshold?: number
24+
}
25+
26+
export type ScrollPaginationState = "idle" | "loading" | "end"
27+
28+
export function useScrollPagination(params: Accessor<UseScrollPaginationOptions>) {
29+
const [_containerRef, setContainerRef] = createSignal<HTMLElement>()
30+
const { ref, entry } = useIntersection({
31+
root: _containerRef(),
32+
threshold: params().threshold ?? 0.75,
33+
})
34+
35+
createEffect(
36+
// Strictly only trigger on intersection. Very hard bug I encountered was changes in isLoading/hasMore are triggering onLoadMore no matter what.
37+
on([entry], () => {
38+
const el = entry()
39+
if (el?.isIntersecting && !params().isLoading && params().hasMore) {
40+
params().onLoadMore()
41+
}
42+
})
43+
)
44+
45+
const loadTriggerState = createMemo(() => {
46+
if (params().isLoading) return "loading"
47+
if (!params().hasMore) return "end"
48+
return "idle"
49+
})
50+
51+
return {
52+
/** No need to assign since it's viewport by default. */
53+
scrollContainerRef: setContainerRef,
54+
loadTriggerRef: ref,
55+
loadTriggerState,
56+
/** For debugging */
57+
_entry: entry,
58+
}
59+
}
60+
61+
// ===========================================================================
62+
// COMPONENT
63+
// ===========================================================================
64+
65+
interface ScrollPaginationObserverProps {
66+
ref: (element: any) => void
67+
state: Accessor<ScrollPaginationState>
68+
class?: string
69+
}
70+
/**
71+
* Usage: (Recommended TanStack)
72+
*
73+
* const { loadTriggerRef, loadTriggerState } = useScrollPagination({
74+
* onLoadMore: () => infiniteQuery.fetchNextPage()
75+
* hasMore: infiniteQuery.hasNextPage,
76+
* isLoading: infiniteQuery.isLoading
77+
* })
78+
*
79+
* <ScrollPaginationObserver ref={loadTriggerRef} state={loadTriggerState} />
80+
*/
81+
export function ScrollPaginationObserver(props: VoidProps<ScrollPaginationObserverProps>) {
82+
return (
83+
<div
84+
class={cn("flex h-20 items-center justify-center text-gray-500", props.class)}
85+
ref={props.ref}
86+
>
87+
<Show when={props.state() !== "idle"}>
88+
<Switch>
89+
<Match when={props.state() === "loading"}>
90+
<IconLoading class="size-8" />
91+
</Match>
92+
<Match when={props.state() === "end"}>You've reached the end.</Match>
93+
</Switch>
94+
</Show>
95+
</div>
96+
)
97+
}

src/pages/_components/+Page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { useThemeContext } from "@/contexts/theme.context"
3939
import { Tippy } from "@/lib/solid-tippy"
4040
import { cn } from "@/utils/cn"
4141
import { DragExample } from "./drag-example"
42+
import { ScrollPaginationExample } from "./scroll-pagination-example"
4243

4344
export default function ComponentsPage() {
4445
return (
@@ -916,6 +917,10 @@ export default function ComponentsPage() {
916917
</span>
917918
<DragExample />
918919
</ComponentCard>
920+
921+
<ComponentCard label="Scroll Pagination">
922+
<ScrollPaginationExample />
923+
</ComponentCard>
919924
</div>
920925
)
921926
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useInfiniteQuery, useQueryClient } from "@tanstack/solid-query"
2+
import { For } from "solid-js"
3+
import {
4+
ScrollPaginationObserver,
5+
useScrollPagination,
6+
} from "@/components/scroll-pagination-observer"
7+
import { Badge } from "@/components/ui/badge"
8+
import { Button } from "@/components/ui/button"
9+
10+
// Mock async fetcher that returns a page of items
11+
const all = Array.from({ length: 20 }, (_, i) => ({
12+
id: i + 1,
13+
title: `Item ${i + 1}`,
14+
}))
15+
const fetchPage = async (page: number) => {
16+
await new Promise((r) => setTimeout(r, 1000)) // simulate network delay
17+
const limit = 3 // default from paginationDTO
18+
const start = (page - 1) * limit // pages start at 1
19+
const items = all.slice(start, start + limit)
20+
return {
21+
items,
22+
hasMore: start + limit < all.length,
23+
total: all.length,
24+
page,
25+
limit,
26+
}
27+
}
28+
29+
export function ScrollPaginationExample() {
30+
const queryClient = useQueryClient()
31+
32+
const infiniteQuery = useInfiniteQuery(() => ({
33+
queryKey: ["scroll-pagination-items"],
34+
queryFn: async ({ pageParam }) => fetchPage(pageParam),
35+
initialPageParam: 1,
36+
getNextPageParam: (lastPage) => {
37+
return lastPage.hasMore ? lastPage.page + 1 : undefined
38+
},
39+
}))
40+
const { scrollContainerRef, loadTriggerRef, loadTriggerState, _entry } = useScrollPagination(
41+
() => ({
42+
onLoadMore: () => infiniteQuery.fetchNextPage(),
43+
isLoading: infiniteQuery.isFetching,
44+
hasMore: infiniteQuery.hasNextPage,
45+
})
46+
)
47+
48+
// flatten pages into one list
49+
const items = () => infiniteQuery.data?.pages.flatMap((p) => p.items) ?? []
50+
51+
const handleReset = () => {
52+
queryClient.resetQueries({ queryKey: ["scroll-pagination-items"] })
53+
}
54+
55+
return (
56+
<div class="w-72 p-6">
57+
<Badge variant={_entry()?.isIntersecting ? "success" : "info"}>
58+
{_entry()?.isIntersecting ? "Intersecting" : "Idle"}
59+
</Badge>
60+
<div class="mb-4 flex items-center justify-between gap-5 text-xs">
61+
<h2 class="">Items ({items().length}/ 20)</h2>
62+
<Button onClick={handleReset} variant="ghost" size="sm">
63+
Reset
64+
</Button>
65+
</div>
66+
67+
<div class="h-full max-h-52 overflow-y-auto" ref={scrollContainerRef}>
68+
<ul class="space-y-2">
69+
<For each={items()}>
70+
{(item) => <li class="rounded border p-3 shadow-sm">{item.title}</li>}
71+
</For>
72+
</ul>
73+
74+
<ScrollPaginationObserver ref={loadTriggerRef} state={loadTriggerState} />
75+
</div>
76+
</div>
77+
)
78+
}

src/server/dtos/pagination.dto.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,25 @@ export const DEFAULT_PAGINATION_OPTIONS: PaginationOptionsDTO = {
1111
page: 1,
1212
limit: 10,
1313
}
14+
15+
export const paginatedResponseDTO = <T extends z.ZodTypeAny>(itemSchema: T) =>
16+
z.object({
17+
/** Array of items for the current page */
18+
items: z.array(itemSchema),
19+
/** Indicates if more pages are available */
20+
hasMore: z.boolean(),
21+
/** Total number of items across all pages */
22+
total: z.number().int().min(0),
23+
/** Current page number (starts at 1) */
24+
page: z.number().int().min(1),
25+
/** Number of items per page */
26+
limit: z.number().int().min(0),
27+
})
28+
29+
export type PaginatedResponseDTO<T> = {
30+
items: T[]
31+
hasMore: boolean
32+
total: number
33+
page: number
34+
limit: number
35+
}

0 commit comments

Comments
 (0)