diff --git a/cypress/e2e/components/Pagination.spec.ts b/cypress/e2e/components/Pagination.spec.ts new file mode 100644 index 000000000..307a42c26 --- /dev/null +++ b/cypress/e2e/components/Pagination.spec.ts @@ -0,0 +1,30 @@ +describe("Pagination", () => { + describe("with scrolling", () => { + beforeEach(() => { + cy.renderFromStorybook("pagination--scroll-after-pagination"); + }); + + it("does not scroll after pagination by default", () => { + cy.get('[aria-label="scroll target"]').contains("None").click(); + + cy.get('[aria-label="Pagination navigation"]').contains("Next").click(); + cy.isNotInViewport('[data-testid="page-heading"'); + }); + + it("scrolls to the top of the page after pagination when scrollToTopAfterPagination is true", () => { + cy.get('[aria-label="scroll target"]').contains("Top of page").click(); + + cy.get('[aria-label="Pagination navigation"]').contains("Next").click(); + cy.get('[aria-label="Pagination navigation"]').contains("Previous").click(); + cy.isInViewport('[data-testid="page-heading"'); + }); + + it("scrolls to the top of the target element when scrollToTopAfterPagination is true and a scrollTargetRef is present", () => { + cy.get('[aria-label="scroll target"]').contains("Top of section").click(); + + cy.get('[aria-label="Pagination navigation"]').contains("7").click(); + cy.isNotInViewport('[data-testid="page-heading"'); + cy.isInViewport('[data-testid="section-heading"'); + }); + }); +}); diff --git a/src/Pagination/NextButton.tsx b/src/Pagination/NextButton.tsx index ef0d60739..242780fb6 100644 --- a/src/Pagination/NextButton.tsx +++ b/src/Pagination/NextButton.tsx @@ -1,11 +1,17 @@ -// @ts-nocheck -import React from "react"; +import React, { ReactNode } from "react"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import { Icon } from "../Icon"; import PaginationButton from "./PaginationButton"; -const NextButton = ({ disabled, onClick, label, "aria-label": ariaLabel }) => { +type NextButtonProps = { + disabled: boolean; + onClick: React.MouseEventHandler; + label: ReactNode; + ariaLabel: string; +}; + +const NextButton = ({ disabled, onClick, label, ariaLabel }: NextButtonProps) => { const { t } = useTranslation(); return ( @@ -18,14 +24,12 @@ NextButton.propTypes = { disabled: PropTypes.bool, onClick: PropTypes.func, label: PropTypes.node, - "aria-label": PropTypes.string, + ariaLabel: PropTypes.string, }; NextButton.defaultProps = { disabled: false, onClick: null, - label: undefined, - "aria-label": undefined, }; export default NextButton; diff --git a/src/Pagination/PageNumber.tsx b/src/Pagination/PageNumber.tsx index eb4b60a46..6fe2d6f3f 100644 --- a/src/Pagination/PageNumber.tsx +++ b/src/Pagination/PageNumber.tsx @@ -1,7 +1,11 @@ import styled from "styled-components"; import PaginationButton from "./PaginationButton"; -const PageNumber = styled(PaginationButton)(({ theme, currentPage }: any) => ({ +type PageNumberProps = { + currentPage: boolean; +}; + +const PageNumber = styled(PaginationButton)(({ theme, currentPage }) => ({ background: currentPage ? theme.colors.darkBlue : "transparent", color: currentPage ? theme.colors.whiteGrey : theme.colors.black, })); diff --git a/src/Pagination/Pagination.spec.tsx b/src/Pagination/Pagination.spec.tsx index 62ab5ee5a..574af3069 100644 --- a/src/Pagination/Pagination.spec.tsx +++ b/src/Pagination/Pagination.spec.tsx @@ -1,29 +1,29 @@ import React from "react"; import { fireEvent } from "@testing-library/react"; import { renderWithNDSProvider } from "../NDSProvider/renderWithNDSProvider.spec-utils"; -import { getPageItemstoDisplay } from "./Pagination"; +import { getPageItemsToDisplay } from "./Pagination"; import { Pagination } from "."; describe("Pagination", () => { describe("truncation", () => { it("it returns an array of page numbers without truncation when there are less than 6 pages", () => { - expect(getPageItemstoDisplay(6, 2)).toEqual([1, 2, 3, 4, 5, 6]); - expect(getPageItemstoDisplay(1, 1)).toEqual([1]); - expect(getPageItemstoDisplay(5, 1)).toEqual([1, 2, 3, 4, 5]); + expect(getPageItemsToDisplay(6, 2)).toEqual([1, 2, 3, 4, 5, 6]); + expect(getPageItemsToDisplay(1, 1)).toEqual([1]); + expect(getPageItemsToDisplay(5, 1)).toEqual([1, 2, 3, 4, 5]); }); it("it returns an array of page numbers with truncation at the beginning when current page is 5 pages from the end", () => { - expect(getPageItemstoDisplay(12, 10)).toEqual([1, "...", 8, 9, 10, 11, 12]); - expect(getPageItemstoDisplay(20, 20)).toEqual([1, "...", 16, 17, 18, 19, 20]); - expect(getPageItemstoDisplay(12, 8)).toEqual([1, "...", 8, 9, 10, 11, 12]); + expect(getPageItemsToDisplay(12, 10)).toEqual([1, "...", 8, 9, 10, 11, 12]); + expect(getPageItemsToDisplay(20, 20)).toEqual([1, "...", 16, 17, 18, 19, 20]); + expect(getPageItemsToDisplay(12, 8)).toEqual([1, "...", 8, 9, 10, 11, 12]); }); it("it returns an array of page numbers with truncation at the end when current page is 5 pages from the beginning", () => { - expect(getPageItemstoDisplay(15, 1)).toEqual([1, 2, 3, 4, 5, "...", 15]); - expect(getPageItemstoDisplay(7, 5)).toEqual([1, 2, 3, 4, 5, "...", 7]); - expect(getPageItemstoDisplay(8, 2)).toEqual([1, 2, 3, 4, 5, "...", 8]); + expect(getPageItemsToDisplay(15, 1)).toEqual([1, 2, 3, 4, 5, "...", 15]); + expect(getPageItemsToDisplay(7, 5)).toEqual([1, 2, 3, 4, 5, "...", 7]); + expect(getPageItemsToDisplay(8, 2)).toEqual([1, 2, 3, 4, 5, "...", 8]); }); it("it returns an array of page numbers with truncation at the both sides is in the middle", () => { - expect(getPageItemstoDisplay(15, 6)).toEqual([1, "...", 5, 6, 7, 8, "...", 15]); - expect(getPageItemstoDisplay(15, 10)).toEqual([1, "...", 9, 10, 11, 12, "...", 15]); + expect(getPageItemsToDisplay(15, 6)).toEqual([1, "...", 5, 6, 7, 8, "...", 15]); + expect(getPageItemsToDisplay(15, 10)).toEqual([1, "...", 9, 10, 11, 12, "...", 15]); }); }); describe("callbacks", () => { diff --git a/src/Pagination/Pagination.story.tsx b/src/Pagination/Pagination.story.tsx index cf2f2dfd5..8d78ee03f 100644 --- a/src/Pagination/Pagination.story.tsx +++ b/src/Pagination/Pagination.story.tsx @@ -1,5 +1,9 @@ -import React from "react"; +import React, { useRef, useState } from "react"; import { action } from "@storybook/addon-actions"; +import { Switch, Switcher } from "../Switcher"; +import { Flex } from "../Flex"; +import { Heading1, Text } from "../Type"; +import { Box } from "../Box"; import { Pagination } from "."; export default { @@ -41,3 +45,56 @@ export const WithLessThan5Pages = () => + + + Scroll target after pagination + + + None + Top of page + Top of section + + + + {messages[scrollTarget].page} + + + {messages[scrollTarget].section} + + setCurrentPage((p) => p + 1)} + onPrevious={() => setCurrentPage((p) => p - 1)} + onSelectPage={(page) => { + setCurrentPage(Number(page)); + }} + /> + + ); +} diff --git a/src/Pagination/Pagination.tsx b/src/Pagination/Pagination.tsx index 44b6bcf7f..ab71dccb9 100644 --- a/src/Pagination/Pagination.tsx +++ b/src/Pagination/Pagination.tsx @@ -1,59 +1,94 @@ -// @ts-nocheck -import React from "react"; +import React, { ReactNode, RefObject } from "react"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; +import { flushSync } from "react-dom"; import { Flex } from "../Flex"; import { Text } from "../Type"; +import { FlexProps } from "../Flex/Flex"; import PageNumber from "./PageNumber"; import PreviousButton from "./PreviousButton"; import NextButton from "./NextButton"; -const SEPERATOR = "..."; +const SEPARATOR = "..."; -export const getPageItemstoDisplay = (totalPages, currentPage) => { - const pages = Array.from({ length: totalPages }, (v, k) => k + 1); +export const getPageItemsToDisplay = (totalPages: number, currentPage: number) => { const MAX_PAGES_TO_SHOW = 6; + + const pages = Array.from({ length: totalPages }, (v, i) => i + 1); + if (totalPages <= MAX_PAGES_TO_SHOW) return pages; if (currentPage <= MAX_PAGES_TO_SHOW - 1) { - return [...pages.slice(0, 5), SEPERATOR, totalPages]; + return [...pages.slice(0, 5), SEPARATOR, totalPages]; } if (currentPage > totalPages - 5) { - return [1, SEPERATOR, ...pages.slice(totalPages - 5)]; + return [1, SEPARATOR, ...pages.slice(totalPages - 5)]; } - return [1, SEPERATOR, ...pages.slice(currentPage - 2, currentPage + 2), SEPERATOR, totalPages]; + return [1, SEPARATOR, ...pages.slice(currentPage - 2, currentPage + 2), SEPARATOR, totalPages]; +}; + +type PaginationProps = FlexProps & { + currentPage: number; + totalPages: number; + onNext?: () => void; + onPrevious?: () => void; + onSelectPage?: (page: string | number) => void; + nextLabel?: ReactNode; + nextAriaLabel?: string; + previousLabel?: ReactNode; + previousAriaLabel?: string; + scrollToTopAfterPagination?: boolean; + scrollTargetRef?: RefObject; }; -const Pagination: React.FC = (props) => { - const { - currentPage, - totalPages, - onNext, - onPrevious, - onSelectPage, - nextAriaLabel, - nextLabel, - previousAriaLabel, - previousLabel, - "aria-label": ariaLabel, - ...restProps - } = props; +function Pagination({ + currentPage, + totalPages, + onNext, + onPrevious, + onSelectPage, + nextAriaLabel, + nextLabel, + previousAriaLabel, + previousLabel, + scrollToTopAfterPagination, + scrollTargetRef, + "aria-label": ariaLabel, + ...restProps +}: PaginationProps) { const { t } = useTranslation(); + + const scrollToTop = () => { + if (scrollToTopAfterPagination) { + const top = scrollTargetRef?.current ? window.scrollY + scrollTargetRef.current?.getBoundingClientRect()?.top : 0; + + window.scrollTo({ + top, + behavior: "smooth", + }); + } + }; + return ( { + flushSync(() => { + onPrevious(); + }); + + scrollToTop(); + }} ariaLabel={previousAriaLabel} label={previousLabel} /> - {getPageItemstoDisplay(totalPages, currentPage).map((page, index) => { + {getPageItemsToDisplay(totalPages, currentPage).map((page, index) => { const isCurrentPage = currentPage === page; - if (page === SEPERATOR) + if (page === SEPARATOR) return ( - // eslint-disable-next-line react/no-array-index-key - {SEPERATOR} + {SEPARATOR} ); else @@ -62,18 +97,35 @@ const Pagination: React.FC = (props) => { aria-current={isCurrentPage} currentPage={isCurrentPage} disabled={isCurrentPage} - aria-label={isCurrentPage ? null : t("go to page", { count: page })} + aria-label={isCurrentPage ? null : t("go to page", { count: Number(page) })} key={page} - onClick={() => onSelectPage(page)} + onClick={() => { + flushSync(() => { + onSelectPage(page); + }); + + scrollToTop(); + }} > {page} ); })} - + { + flushSync(() => { + onNext(); + }); + + scrollToTop(); + }} + ariaLabel={nextAriaLabel} + label={nextLabel} + /> ); -}; +} Pagination.propTypes = { currentPage: PropTypes.number.isRequired, diff --git a/src/Pagination/PaginationButton.tsx b/src/Pagination/PaginationButton.tsx index 1c4aeb81a..fb81567ff 100644 --- a/src/Pagination/PaginationButton.tsx +++ b/src/Pagination/PaginationButton.tsx @@ -1,11 +1,12 @@ import styled from "styled-components"; +import { DefaultNDSThemeType } from "../theme.type"; -const getHoverBackground = (currentPage, disabled, theme) => { +const getHoverBackground = (currentPage: boolean, disabled: boolean, theme: DefaultNDSThemeType) => { if (currentPage) { return theme.colors.darkBlue; } if (disabled) { - return "inital"; + return "initial"; } return theme.colors.lightGrey; }; diff --git a/src/Pagination/PreviousButton.tsx b/src/Pagination/PreviousButton.tsx index a9b286bbf..b95bd2599 100644 --- a/src/Pagination/PreviousButton.tsx +++ b/src/Pagination/PreviousButton.tsx @@ -1,11 +1,17 @@ -// @ts-nocheck -import React from "react"; +import React, { ReactNode } from "react"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import { Icon } from "../Icon"; import PaginationButton from "./PaginationButton"; -const PreviousButton = ({ disabled, onClick, label, "aria-label": ariaLabel }: any) => { +type PreviousButtonProps = { + disabled: boolean; + onClick: React.MouseEventHandler; + label: ReactNode; + ariaLabel: string; +}; + +const PreviousButton = ({ disabled, onClick, label, ariaLabel }: PreviousButtonProps) => { const { t } = useTranslation(); return ( @@ -18,14 +24,12 @@ PreviousButton.propTypes = { disabled: PropTypes.bool, onClick: PropTypes.func, label: PropTypes.node, - "aria-label": PropTypes.string, + ariaLabel: PropTypes.string, }; PreviousButton.defaultProps = { disabled: false, onClick: null, - label: undefined, - "aria-label": undefined, }; export default PreviousButton;