Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/next-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@zag-js/hover-card": "workspace:*",
"@zag-js/i18n-utils": "workspace:*",
"@zag-js/image-cropper": "workspace:*",
"@zag-js/infinite-scroll": "workspace:*",
"@zag-js/interact-outside": "workspace:*",
"@zag-js/json-tree-utils": "workspace:*",
"@zag-js/gridlist": "workspace:*",
Expand Down
79 changes: 79 additions & 0 deletions examples/next-ts/pages/infinite-scroll/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createInfiniteScroll, getSentinelProps } from "@zag-js/infinite-scroll"
import { useEffect, useRef, useState } from "react"

const PAGE_SIZE = 20
const TOTAL = 200

const loadPage = (page: number): Promise<string[]> =>
new Promise((resolve) => {
setTimeout(() => {
const start = page * PAGE_SIZE
resolve(Array.from({ length: PAGE_SIZE }, (_, i) => `Item ${start + i + 1}`))
}, 600)
})

export default function Page() {
const [items, setItems] = useState<string[]>([])
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(false)

const sentinelRef = useRef<HTMLLIElement>(null)
const scrollerRef = useRef<HTMLDivElement>(null)

const hasMore = items.length < TOTAL

useEffect(() => {
if (!sentinelRef.current || !scrollerRef.current) return
return createInfiniteScroll({
sentinelEl: sentinelRef.current,
scrollerEl: scrollerRef.current,
hasMore,
loading,
threshold: "100px",
onLoadMore: async () => {
setLoading(true)
const next = await loadPage(page)
setItems((prev) => [...prev, ...next])
setPage((p) => p + 1)
setLoading(false)
},
})
}, [hasMore, loading, page])

return (
<main className="infinite-scroll">
<h1>Infinite Scroll — Basic</h1>
<p>
Loaded {items.length} / {TOTAL}
</p>
<div
ref={scrollerRef}
style={{
height: 320,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 8,
}}
>
<ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
{items.map((label, i) => (
<li
key={i}
style={{
padding: "12px 16px",
borderBottom: "1px solid #eee",
}}
>
{label}
</li>
))}
<li ref={sentinelRef} {...getSentinelProps()} />
</ul>
<div style={{ padding: 12, textAlign: "center", color: "#666" }}>
{loading && "Loading…"}
{!loading && !hasMore && "End of list"}
</div>
</div>
</main>
)
}
72 changes: 72 additions & 0 deletions examples/next-ts/pages/infinite-scroll/with-async-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as asyncList from "@zag-js/async-list"
import { createInfiniteScroll, getSentinelProps } from "@zag-js/infinite-scroll"
import { useMachine } from "@zag-js/react"
import { useEffect, useRef } from "react"

interface Person {
name: string
url: string
}

export default function Page() {
const service = useMachine(asyncList.machine as asyncList.Machine<Person>, {
async load({ signal, cursor }) {
if (cursor) cursor = cursor.replace(/^http:\/\//i, "https://")
const res = await fetch(cursor || "https://swapi.py4e.com/api/people/", { signal })
const json = await res.json()
return { items: json.results, cursor: json.next }
},
})

const api = asyncList.connect(service)

const sentinelRef = useRef<HTMLLIElement>(null)
const scrollerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!sentinelRef.current || !scrollerRef.current) return
return createInfiniteScroll({
sentinelEl: sentinelRef.current,
scrollerEl: scrollerRef.current,
hasMore: api.hasMore,
loading: api.isLoading,
threshold: "200px",
onLoadMore: () => api.loadMore(),
})
}, [api.hasMore, api.isLoading])

return (
<main className="infinite-scroll">
<h1>Infinite Scroll — Async List</h1>
<p>Loaded {api.items.length}</p>
<div
ref={scrollerRef}
style={{
height: 360,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 8,
}}
>
<ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
{api.items.map((item) => (
<li
key={item.url}
style={{
padding: "12px 16px",
borderBottom: "1px solid #eee",
}}
>
{item.name}
</li>
))}
<li ref={sentinelRef} {...getSentinelProps()} />
</ul>
<div style={{ padding: 12, textAlign: "center", color: "#666" }}>
{api.isLoading && "Loading…"}
{!api.isLoading && !api.hasMore && "End of list"}
</div>
</div>
</main>
)
}
118 changes: 118 additions & 0 deletions examples/next-ts/pages/infinite-scroll/with-virtualizer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { createInfiniteScroll } from "@zag-js/infinite-scroll"
import { ListVirtualizer } from "@zag-js/virtualizer"
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"

const PAGE_SIZE = 50
const TOTAL = 5000

const loadPage = (page: number): Promise<string[]> =>
new Promise((resolve) => {
setTimeout(() => {
const start = page * PAGE_SIZE
resolve(Array.from({ length: PAGE_SIZE }, (_, i) => `Row ${start + i + 1}`))
}, 500)
})

export default function Page() {
const [items, setItems] = useState<string[]>([])
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(false)

const hasMore = items.length < TOTAL

// Add a placeholder slot at the end while there's more to load.
// The virtualizer renders that slot as our sentinel.
const count = hasMore ? items.length + 1 : items.length

const virtualizer = useMemo(
() =>
new ListVirtualizer({
count,
estimatedSize: () => 44,
overscan: 5,
}),
[count],
)

useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)

const scrollRef = useRef<HTMLDivElement>(null)

const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
scrollRef.current = el
if (el) virtualizer.init(el)
},
[virtualizer],
)

useEffect(() => () => virtualizer.destroy(), [virtualizer])

const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()

useEffect(() => {
const lastItem = virtualItems.at(-1)
if (!lastItem || !scrollRef.current) return

// The sentinel is the last virtual row when more data is available.
const sentinelEl = scrollRef.current.querySelector<HTMLElement>(`[data-index="${lastItem.index}"]`)
if (!sentinelEl) return

return createInfiniteScroll({
sentinelEl,
scrollerEl: scrollRef.current,
hasMore,
loading,
onLoadMore: async () => {
setLoading(true)
const next = await loadPage(page)
setItems((prev) => [...prev, ...next])
setPage((p) => p + 1)
setLoading(false)
},
})
}, [virtualItems, hasMore, loading, page])

return (
<main className="infinite-scroll">
<h1>Infinite Scroll — Virtualized</h1>
<p>
Loaded {items.length} / {TOTAL}
</p>
<div
ref={setScrollRef}
onScroll={virtualizer.handleScroll}
{...virtualizer.getContainerAriaAttrs()}
style={{
...virtualizer.getContainerStyle(),
height: 400,
border: "1px solid #ddd",
borderRadius: 8,
}}
>
<div style={{ height: totalSize, width: "100%", position: "relative" }}>
{virtualItems.map((virtualItem) => {
const isSentinelRow = hasMore && virtualItem.index === items.length
const style = virtualizer.getItemStyle(virtualItem)
return (
<div
key={virtualItem.index}
data-index={virtualItem.index}
{...virtualizer.getItemAriaAttrs(virtualItem.index)}
style={{
...style,
padding: "12px 16px",
borderBottom: "1px solid #eee",
color: isSentinelRow ? "#888" : undefined,
}}
>
{isSentinelRow ? (loading ? "Loading…" : "") : items[virtualItem.index]}
</div>
)
})}
</div>
</div>
</main>
)
}
22 changes: 22 additions & 0 deletions packages/utilities/infinite-scroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# @zag-js/infinite-scroll

Framework agnostic infinite scroll utility based on `IntersectionObserver`.

## Installation

```sh
yarn add @zag-js/infinite-scroll
# or
npm i @zag-js/infinite-scroll
```

## Contribution

Yes please! See the
[contributing guidelines](https://github.com/chakra-ui/zag/blob/main/CONTRIBUTING.md)
for details.

## Licence

This project is licensed under the terms of the
[MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE).
40 changes: 40 additions & 0 deletions packages/utilities/infinite-scroll/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@zag-js/infinite-scroll",
"version": "0.0.0",
"description": "Framework agnostic infinite scroll utility based on IntersectionObserver",
"keywords": [
"js",
"utils",
"infinite-scroll",
"intersection-observer"
],
"author": "Segun Adebayo <sage@adebayosegun.com>",
"homepage": "https://github.com/chakra-ui/zag#readme",
"license": "MIT",
"main": "src/index.ts",
"repository": "https://github.com/chakra-ui/zag/tree/main/packages/utilities/infinite-scroll",
"sideEffects": false,
"files": [
"dist/**/*"
],
"scripts": {
"build": "tsup",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/chakra-ui/zag/issues"
},
"clean-package": "../../../clean-package.config.json",
"dependencies": {
"@zag-js/dom-query": "workspace:*"
},
"devDependencies": {
"clean-package": "2.2.0"
}
}
Loading