Skip to content

Commit

Permalink
Add loading indicator on paged list
Browse files Browse the repository at this point in the history
  • Loading branch information
pookmish committed Jul 10, 2024
1 parent 16923c5 commit a7ea4bf
Show file tree
Hide file tree
Showing 4 changed files with 584 additions and 1,529 deletions.
24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@heroicons/react": "^2.1.4",
"@heroicons/react": "^2.1.5",
"@js-temporal/polyfill": "^0.4.4",
"@mui/base": "5.0.0-dev.20240529-082515-213b5e33ab",
"@next/third-parties": "^14.2.4",
"@next/third-parties": "^14.2.5",
"@tailwindcss/container-queries": "^0.1.1",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
Expand All @@ -30,13 +30,13 @@
"decanter": "^7.3.0",
"drupal-jsonapi-params": "^2.3.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.4",
"eslint-config-next": "^14.2.5",
"graphql": "^16.9.0",
"graphql-request": "^7.1.0",
"graphql-tag": "^2.12.6",
"html-entities": "^2.5.2",
"html-react-parser": "^5.1.10",
"next": "^14.2.4",
"next": "^14.2.5",
"next-drupal": "^1.6.0",
"postcss": "^8.4.39",
"qs": "^6.12.3",
Expand All @@ -59,14 +59,14 @@
"@graphql-codegen/import-types-preset": "^3.0.0",
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
"@graphql-codegen/typescript-operations": "^4.2.3",
"@next/bundle-analyzer": "^14.2.4",
"@storybook/addon-essentials": "^8.1.11",
"@storybook/addon-interactions": "^8.1.11",
"@storybook/addon-links": "^8.1.11",
"@next/bundle-analyzer": "^14.2.5",
"@storybook/addon-essentials": "^8.2.1",
"@storybook/addon-interactions": "^8.2.1",
"@storybook/addon-links": "^8.2.1",
"@storybook/addon-styling": "^1.3.7",
"@storybook/blocks": "^8.1.11",
"@storybook/nextjs": "^8.1.11",
"@storybook/react": "^8.1.11",
"@storybook/blocks": "^8.2.1",
"@storybook/nextjs": "^8.2.1",
"@storybook/react": "^8.2.1",
"@storybook/testing-library": "^0.2.2",
"@types/react-slick": "^0.23.13",
"concurrently": "^8.2.2",
Expand All @@ -77,7 +77,7 @@
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"react-docgen": "^7.0.3",
"storybook": "^8.1.11",
"storybook": "^8.2.1",
"storybook-addon-module-mock": "^1.3.0",
"tsconfig-paths-webpack-plugin": "^4.1.0"
},
Expand Down
53 changes: 39 additions & 14 deletions src/components/elements/paged-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {useRouter, useSearchParams} from "next/navigation"
import usePagination from "@lib/hooks/usePagination"
import useFocusOnRender from "@lib/hooks/useFocusOnRender"
import {ArrowLongLeftIcon, ArrowLongRightIcon} from "@heroicons/react/20/solid"
import {ArrowPathIcon} from "@heroicons/react/16/solid"
import {twMerge} from "tailwind-merge"
import useServerAction from "@lib/hooks/useServerAction"

type Props = HtmlHTMLAttributes<HTMLDivElement> & {
/**
Expand Down Expand Up @@ -38,32 +41,37 @@ type Props = HtmlHTMLAttributes<HTMLDivElement> & {
const PagedList = ({children, ulProps, liProps, pageKey = "page", totalPages, pagerSiblingCount = 2, loadPage, ...props}: Props) => {
const ref = useRef(false)
const [items, setItems] = useState<JSX.Element[]>(Array.isArray(children) ? children : [children])
const [runAction, isRunning] = useServerAction<[number], JSX.Element>(loadPage)

const router = useRouter()
const searchParams = useSearchParams()

// Use the GET param for page, but make sure that it is between 1 and the last page. If it's a string or a number
// outside the range, fix the value, so it works as expected.
const {count: currentPage, setCount: setPage} = useCounter(Math.max(1, parseInt(searchParams.get(pageKey || "") || "") || 1))
const {count: currentPage, setCount: setPage} = useCounter(Math.min(totalPages, pageKey ? Math.max(1, parseInt(searchParams.get(pageKey) || "")) : 1))
const {value: focusOnElement, setTrue: enableFocusElement, setFalse: disableFocusElement} = useBoolean(false)

const focusItemRef = useRef<HTMLLIElement>(null)
const [animationParent] = useAutoAnimate<HTMLUListElement>()

const goToPage = useCallback(
async (page: number, doNotFocusOnResults?: boolean) => {
if (loadPage) {
loadPage(page - 1)
.then(response => {
(page: number, doNotFocusOnResults?: boolean) => {
runAction(page - 1)
.then(response => {
if (response) {
// Set the rendering to the response from the server. If the response has a suspense boundary, it will have a
// fallback prop. Then we only want to render the list of children within the suspense.
setItems(response.props.fallback ? response.props.children.props.children : response.props.children)

// When loading a page during the initial page load, we don't want to focus on anything. But when a user changes
// pages, we want to focus on the first element.
if (!doNotFocusOnResults) enableFocusElement()
setPage(page)
})
.catch(() => console.warn("An error occurred fetching more results."))
}
}
})
.catch(() => console.warn("An error occurred fetching more results."))
},
[enableFocusElement, setPage, loadPage]
[enableFocusElement, setPage, runAction]
)

const setFocusOnItem = useFocusOnRender(focusItemRef, false)
Expand All @@ -87,15 +95,30 @@ const PagedList = ({children, ulProps, liProps, pageKey = "page", totalPages, pa
}, [loadPage, router, currentPage, pageKey, searchParams])

useEffect(() => {
const initialPage = parseInt(searchParams.get(pageKey || "") || "")
if (initialPage > 1 && !ref.current) goToPage(initialPage, true)
const initialPage = pageKey ? parseInt(searchParams.get(pageKey) || "") : 1
if (initialPage > 1 && !ref.current) {
goToPage(Math.max(1, Math.min(totalPages, initialPage)), true)
}
ref.current = true
}, [searchParams, pageKey, loadPage, goToPage])
}, [searchParams, pageKey, loadPage, goToPage, totalPages])

const paginationButtons = usePagination(totalPages * items.length, currentPage, items.length, pagerSiblingCount)

return (
<div {...props}>
<div
{...props}
className={twMerge("relative", props.className)}
>
{isRunning && (
<div className="absolute left-0 top-0 z-10 h-full w-full rounded-2xl bg-black-20 bg-opacity-30">
<div className="absolute bottom-20 left-1/2 -translate-x-1/2">
<ArrowPathIcon
className="animate-spin"
width={50}
/>
</div>
</div>
)}
<ul
{...ulProps}
ref={animationParent}
Expand Down Expand Up @@ -127,6 +150,7 @@ const PagedList = ({children, ulProps, liProps, pageKey = "page", totalPages, pa
total={totalPages}
onPageClick={goToPage}
pagerSiblingCount={pagerSiblingCount}
disabled={isRunning}
/>
))}
</ul>
Expand All @@ -136,7 +160,7 @@ const PagedList = ({children, ulProps, liProps, pageKey = "page", totalPages, pa
)
}

const PaginationButton = ({page, currentPage, total, onPageClick, pagerSiblingCount}: {page: number | string; currentPage: number; total: number; onPageClick: (_page: number) => void; pagerSiblingCount: number}) => {
const PaginationButton = ({page, currentPage, total, onPageClick, pagerSiblingCount, disabled}: {page: number | string; currentPage: number; total: number; onPageClick: (_page: number) => void; pagerSiblingCount: number; disabled: boolean}) => {
if (page === 0) {
return (
<li className="mt-auto h-full">
Expand Down Expand Up @@ -166,6 +190,7 @@ const PaginationButton = ({page, currentPage, total, onPageClick, pagerSiblingCo
className="group text-m2 font-medium hocus:underline"
onClick={handleClick}
aria-current={isCurrent ? "page" : undefined}
disabled={disabled}
>
<span className="sr-only">
{page === "leftArrow" && "Go to first page"}
Expand Down
43 changes: 43 additions & 0 deletions src/lib/hooks/useServerAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {useState, useEffect, useTransition, useRef} from "react"
import {useBoolean} from "usehooks-ts"

/**
* Call a server action and provide a pending state while the process is running.
*
* @see https://github.com/vercel/next.js/discussions/51371#discussioncomment-8671340
*
* @param action
* @param onFinished
*/
const useServerAction = <P extends any[], R>(action?: (..._args: P) => Promise<R>, onFinished?: (_: R | undefined) => void): [(..._args: P) => Promise<R | undefined>, boolean] => {
const [isPending, startTransition] = useTransition()
const [result, setResult] = useState<R>()
const {value: finished, setTrue: setFinished} = useBoolean(false)
const resolver = useRef<(_value?: R | PromiseLike<R>) => void>()

useEffect(() => {
if (!finished) return

if (onFinished) onFinished(result)
resolver.current?.(result)
}, [result, finished, onFinished])

const runAction = async (...args: P): Promise<R | undefined> => {
startTransition(() => {
if (action) {
action(...args).then(data => {
setResult(data)
setFinished()
})
}
})

return new Promise(resolve => {
resolver.current = resolve
})
}

return [runAction, isPending]
}

export default useServerAction
Loading

0 comments on commit a7ea4bf

Please sign in to comment.