composables/useListInfiniteScroll.ts
import {
useDebounceFn,
useInfiniteScroll,
useResizeObserver,
useScroll,
} from '@vueuse/core'
import {
INFINITE_SCROLL_CONTAINER_ID,
INFINITE_SCROLL_ITEM_CLASS_NAME,
} from '@/utils/constants'
type LoadDirection = 'up' | 'down'
export default function ({
defaultFirst,
defaultScrollContainerId,
defaultScrollItemClassName,
gotoPage,
fetchPageData,
}: {
defaultFirst?: number
defaultScrollContainerId?: string
defaultScrollItemClassName?: string
gotoPage: (page: number) => void
fetchPageData: (
page: number,
loadDirection?: LoadDirection,
) => Promise<boolean>
}) {
const route = useRoute()
const router = useRouter()
const { $consola } = useNuxtApp()
const currentPage = ref(parseInt(route.query?.page as string) || 1)
const startPage = ref(currentPage.value)
const endPage = ref(startPage.value)
const scrollItemHeight = ref(300)
const itemsPerRow = ref(4)
const scrollItemSizeInit = ref(false)
const first = ref(defaultFirst || 40)
const total = ref(0)
const isFetchingData = ref(false)
const containerRef = ref<Window>(window)
useInfiniteScroll(
containerRef,
() => {
reachBottomHandler()
},
{
distance: 2000,
},
)
const scrollContainerId = ref(
defaultScrollContainerId ?? INFINITE_SCROLL_CONTAINER_ID,
)
const scrollItemClassName = ref(
defaultScrollItemClassName ?? INFINITE_SCROLL_ITEM_CLASS_NAME,
)
const canLoadNextPage = computed(
() =>
endPage.value < Math.ceil(total.value / first.value) && total.value > 0,
)
const pageHeight = computed(
(): number => scrollItemHeight.value * (first.value / itemsPerRow.value),
)
const updateCurrentPage = () => {
// allow page update only when current path is same as route path
// i.e. scope it to only the page in which useListInfiniteScroll is used
const allowUpdate
= import.meta.client && window.location.pathname === route.path
if (!allowUpdate) {
return
}
const page
= Math.floor(document.documentElement.scrollTop / pageHeight.value)
+ startPage.value
if (page) {
replaceUrlPage(String(page))
currentPage.value = page
}
}
const onResize = useDebounceFn(() => {
try {
const container = document.getElementById(scrollContainerId.value)
const scrollItem = document.body.querySelector(
`.${scrollItemClassName.value}`,
)
if (scrollItem && container) {
scrollItemHeight.value = scrollItem.clientHeight
itemsPerRow.value = Math.max(
Math.floor(container.clientWidth / scrollItem.clientWidth),
1,
)
scrollItemSizeInit.value = true
}
}
catch (err) {
$consola.warn('resize scroll item', err)
}
}, 1000)
useScroll(window, { onScroll: updateCurrentPage, throttle: 1000 })
useResizeObserver(document.body, onResize)
const replaceUrlPage = (targetPage: string) => {
const isFirstPage = targetPage === '1'
if (targetPage === route.query.page || (isFirstPage && !route.query.page)) {
return
}
const { page, ...restQuery } = route.query
router
.replace({
path: String(route.path),
query: isFirstPage
? { ...restQuery }
: { ...restQuery, page: targetPage },
})
.catch($consola.warn)
}
const reachTopHandler = useDebounceFn(() => {
fetchPreviousPage()
}, 1000)
const fetchDataCallback = async (
page: number,
direction: LoadDirection,
successCb: () => void,
) => {
return await fetchPageData(page, direction).then((isSuccess) => {
if (isSuccess) {
successCb()
}
return isSuccess
})
}
const fetchPreviousPage = async () => {
if (startPage.value <= 1) {
return
}
const nextPage = startPage.value - 1
await fetchDataCallback(startPage.value - 1, 'up', () => {
startPage.value = nextPage
checkAfterFetchDataSuccess()
})
}
const reachBottomHandler = useDebounceFn(() => {
fetchNextPage()
}, 1000)
const fetchNextPage = async () => {
if (!canLoadNextPage.value) {
return
}
const nextPage = endPage.value + 1
await fetchDataCallback(nextPage, 'down', () => {
endPage.value = nextPage
checkAfterFetchDataSuccess()
prefetchNextPage()
})
}
const prefetchNextPage = async () => {
updateCurrentPage()
if (endPage.value - currentPage.value <= 1 && canLoadNextPage.value) {
await fetchNextPage()
}
}
const checkAfterFetchDataSuccess = () => {
checkCurrentPageIsValid()
checkScrollItemSize()
}
const checkCurrentPageIsValid = () => {
const maxPage = Math.ceil(total.value / first.value)
if (maxPage > 0 && currentPage.value > maxPage) {
gotoPage(maxPage)
}
}
const checkScrollItemSize = () => {
if (scrollItemSizeInit.value) {
return
}
onResize()
}
return {
isFetchingData,
fetchPreviousPage,
fetchNextPage,
reachTopHandler,
prefetchNextPage,
first,
total,
startPage,
currentPage,
endPage,
scrollItemClassName,
scrollContainerId,
}
}