diff --git a/package.json b/package.json index 2acb3f4..650e53c 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,15 @@ "@formkit/auto-animate": "^0.8.2", "@heroicons/react": "^2.1.3", "@js-temporal/polyfill": "^0.4.4", - "@mui/base": "^5.0.0-beta.42", + "@mui/base": "^5.0.0-beta.46", "@next/third-parties": "^14.2.3", "@tailwindcss/container-queries": "^0.1.1", - "@types/node": "^20.12.7", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", + "@types/node": "^20.12.12", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", "algoliasearch": "^4.23.3", "autoprefixer": "^10.4.19", - "axios": "^1.6.8", + "axios": "^1.7.2", "clsx": "^2.1.1", "decanter": "^7.3.0", "drupal-jsonapi-params": "^2.3.1", @@ -39,14 +39,14 @@ "next-drupal": "^1.6.0", "postcss": "^8.4.38", "qs": "^6.12.1", - "react": "^18.3.0", - "react-dom": "^18.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-focus-lock": "^2.12.1", - "react-instantsearch": "^7.7.2", - "react-instantsearch-nextjs": "^0.2.1", + "react-instantsearch": "^7.9.0", + "react-instantsearch-nextjs": "^0.2.5", "react-slick": "^0.30.2", "react-tiny-oembed": "^1.1.0", - "sharp": "^0.33.3", + "sharp": "^0.33.4", "tailwind-merge": "^2.3.0", "tailwindcss": "^3.4.3", "typescript": "^5.4.5", @@ -57,24 +57,24 @@ "@graphql-codegen/cli": "^5.0.2", "@graphql-codegen/import-types-preset": "^3.0.0", "@graphql-codegen/typescript-graphql-request": "^6.2.0", - "@graphql-codegen/typescript-operations": "^4.2.0", + "@graphql-codegen/typescript-operations": "^4.2.1", "@next/bundle-analyzer": "^14.2.3", - "@storybook/addon-essentials": "^8.0.9", - "@storybook/addon-interactions": "^8.0.9", - "@storybook/addon-links": "^8.0.9", + "@storybook/addon-essentials": "^8.1.3", + "@storybook/addon-interactions": "^8.1.3", + "@storybook/addon-links": "^8.1.3", "@storybook/addon-styling": "^1.3.7", - "@storybook/blocks": "^8.0.9", - "@storybook/nextjs": "^8.0.9", - "@storybook/react": "^8.0.9", + "@storybook/blocks": "^8.1.3", + "@storybook/nextjs": "^8.1.3", + "@storybook/react": "^8.1.3", "@storybook/testing-library": "^0.2.2", "@types/react-slick": "^0.23.13", "concurrently": "^8.2.2", "encoding": "^0.1.13", "eslint-plugin-deprecation": "^2.0.0", "eslint-plugin-storybook": "^0.8.0", - "eslint-plugin-unused-imports": "^3.1.0", + "eslint-plugin-unused-imports": "^3.2.0", "react-docgen": "^7.0.3", - "storybook": "^8.0.9", + "storybook": "^8.1.3", "tsconfig-paths-webpack-plugin": "^4.1.0" }, "packageManager": "yarn@4.1.1" diff --git a/src/components/elements/load-more-list.tsx b/src/components/elements/load-more-list.tsx index 8890c89..48ec2ee 100644 --- a/src/components/elements/load-more-list.tsx +++ b/src/components/elements/load-more-list.tsx @@ -1,6 +1,6 @@ "use client"; -import {useLayoutEffect, useRef, HtmlHTMLAttributes, JSX, useId} from "react"; +import {useLayoutEffect, useRef, HtmlHTMLAttributes, JSX, useId, useState} from "react"; import Button from "@components/elements/button"; import {useAutoAnimate} from "@formkit/auto-animate/react"; import {useBoolean, useCounter} from "usehooks-ts"; @@ -23,19 +23,35 @@ type Props = HtmlHTMLAttributes & { * The number of items per page. */ itemsPerPage?: number + /** + * Elements to display initially. + */ + children: JSX.Element[] + /** + * Server action callback to fetch the next "page" contents. + */ + loadPage?: (_page: number) => Promise } -const LoadMoreList = ({buttonText, children, ulProps, liProps, itemsPerPage = 10, ...props}: Props) => { +const LoadMoreList = ({buttonText, children, ulProps, liProps, loadPage, ...props}: Props) => { const id = useId(); - const {count: shownItems, setCount: setShownItems} = useCounter(itemsPerPage) + const {count: page, increment: incrementPage} = useCounter(0) + const [items, setItems] = useState(children) + const {value: hasMore, setValue: setHasMore} = useBoolean(!!loadPage) const {value: focusOnElement, setTrue: enableFocusElement, setFalse: disableFocusElement} = useBoolean(false) const focusItemRef = useRef(null); const [animationParent] = useAutoAnimate(); - const showMoreItems = () => { + const showMoreItems = async () => { + if (loadPage) { + const results = await loadPage(page + 1); + if (results.props.children.length < 30) setHasMore(false) + setItems([...items, ...results.props.children]) + } + enableFocusElement(); - setShownItems(shownItems + itemsPerPage); + incrementPage() } const setFocusOnItem = useFocusOnRender(focusItemRef, false); @@ -44,18 +60,15 @@ const LoadMoreList = ({buttonText, children, ulProps, liProps, itemsPerPage = 10 if (focusOnElement) setFocusOnItem() }, [focusOnElement, setFocusOnItem]); - const focusingItem = shownItems - itemsPerPage; - const items = Array.isArray(children) ? children : [children] - const itemsToShow = items.slice(0, shownItems); return (
    - {itemsToShow.map((item, i) => + {items.map((item, i) =>
  • @@ -63,14 +76,11 @@ const LoadMoreList = ({buttonText, children, ulProps, liProps, itemsPerPage = 10
  • )}
+ + Showing {items.length} items. + - {items.length > itemsPerPage && - - Showing {itemsToShow.length} of {items.length} total items. - - } - - {items.length > shownItems && + {hasMore && diff --git a/src/components/elements/paged-list.tsx b/src/components/elements/paged-list.tsx index ced99ad..d17191d 100644 --- a/src/components/elements/paged-list.tsx +++ b/src/components/elements/paged-list.tsx @@ -1,7 +1,6 @@ "use client"; -import {useLayoutEffect, useRef, HtmlHTMLAttributes, useEffect} from "react"; -import Button from "@components/elements/button"; +import {useLayoutEffect, useRef, HtmlHTMLAttributes, useEffect, useId, JSX, useState} from "react"; import {useAutoAnimate} from "@formkit/auto-animate/react"; import {useBoolean, useCounter} from "usehooks-ts"; import {useRouter, useSearchParams} from "next/navigation"; @@ -18,30 +17,54 @@ type Props = HtmlHTMLAttributes & { */ liProps?: HtmlHTMLAttributes, /** - * The number of items per page. + * URL parameter used to save the users page position. */ - itemsPerPage?: number + pageKey?: string | false /** - * URL parameter used to save the users page position. + * Number of sibling pager buttons. + */ + pagerSiblingCount?: number + /** + * Total number of pages to build the pager. + */ + totalPages: number + /** + * Server action to load a page. */ - pageKey?: string + loadPage?: (_page: number) => Promise } -const PagedList = ({children, ulProps, liProps, itemsPerPage = 10, pageKey = "page", ...props}: Props) => { - const items = Array.isArray(children) ? children : [children] - +const PagedList = ({ + children, + ulProps, + liProps, + pageKey = "page", + totalPages, + pagerSiblingCount = 2, + loadPage, + ...props +}: Props) => { + + const id = useId(); + const [items, setItems] = useState(Array.isArray(children) ? children : [children]) 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 + // 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: page, setCount: setPage} = useCounter(Math.max(1, Math.min(Math.ceil(items.length / itemsPerPage), parseInt(searchParams.get(pageKey) || "") || 1))) + const {count: currentPage, setCount: setPage} = useCounter(Math.max(1, parseInt(searchParams.get(pageKey || "") || "") || 1)) + const {value: focusOnElement, setTrue: enableFocusElement, setFalse: disableFocusElement} = useBoolean(false) const focusItemRef = useRef(null); const [animationParent] = useAutoAnimate(); - const goToPage = (page: number) => { + const goToPage = async (page: number) => { + if (loadPage) { + const newView = await loadPage(page - 1) + setItems(newView.props.children) + } + enableFocusElement(); setPage(page); } @@ -53,24 +76,41 @@ const PagedList = ({children, ulProps, liProps, itemsPerPage = 10, pageKey = "pa }, [focusOnElement, setFocusOnItem]); useEffect(() => { + if (!pageKey || !loadPage) return; + // Use search params to retain any other parameters. const params = new URLSearchParams(searchParams.toString()); - if (page > 1) { - params.set(pageKey, `${page}`) + if (currentPage > 1) { + params.set(pageKey, `${currentPage}`) } else { params.delete(pageKey) } router.replace(`?${params.toString()}`, {scroll: false}) - }, [router, page, pageKey, searchParams]); - const paginationButtons = usePagination(items.length, page, itemsPerPage, 2); + }, [loadPage, router, currentPage, pageKey, searchParams]); + + useEffect(() => { + + const updateInitialContents = async (initialPage: number) => { + if (loadPage) { + const newView = await loadPage(initialPage - 1) + setItems(newView.props.children) + } + } + + const initialPage = parseInt(searchParams.get(pageKey || "") || ""); + if (initialPage > 1) updateInitialContents(initialPage) + }, [searchParams, pageKey, loadPage]) + + + const paginationButtons = usePagination(totalPages * items.length, currentPage, items.length, pagerSiblingCount); return (
    - {items.slice((page - 1) * itemsPerPage, page * itemsPerPage).map((item, i) => + {items.map((item, i) =>
  • -
      + {(loadPage && paginationButtons.length > 1) && +