Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: optionally scroll to the top after paginating #1314

Merged
merged 2 commits into from
Nov 17, 2023
Merged
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
30 changes: 30 additions & 0 deletions cypress/e2e/components/Pagination.spec.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
});
16 changes: 10 additions & 6 deletions src/Pagination/NextButton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>;
label: ReactNode;
ariaLabel: string;
};

const NextButton = ({ disabled, onClick, label, ariaLabel }: NextButtonProps) => {
const { t } = useTranslation();
return (
<PaginationButton disabled={disabled} onClick={onClick} aria-label={ariaLabel || t("go to next results")}>
Expand All @@ -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;
6 changes: 5 additions & 1 deletion src/Pagination/PageNumber.tsx
Original file line number Diff line number Diff line change
@@ -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)<PageNumberProps>(({ theme, currentPage }) => ({
background: currentPage ? theme.colors.darkBlue : "transparent",
color: currentPage ? theme.colors.whiteGrey : theme.colors.black,
}));
Expand Down
24 changes: 12 additions & 12 deletions src/Pagination/Pagination.spec.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
59 changes: 58 additions & 1 deletion src/Pagination/Pagination.story.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -41,3 +45,56 @@ export const WithLessThan5Pages = () => <Pagination currentPage={3} totalPages={
WithLessThan5Pages.story = {
name: "with less than 5 pages",
};

export function ScrollAfterPagination() {
const [currentPage, setCurrentPage] = useState(1);
const [scrollTarget, setScrollTarget] = useState("none");
const ref = useRef(null);

const messages = {
none: {
page: "The page should not scroll after pagination.",
section: "",
},
topOfPage: {
page: "The page should scroll to the top after pagination.",
section: "",
},
topOfSection: {
page: "This part of the page should not be in the viewport after pagination.",
section: "The page should scroll to the top of this section after pagination.",
},
};

return (
<Flex gap="x2" flexDirection="column" alignItems="flex-end">
<Flex flexDirection="column" gap="x1" alignSelf="flex-start" mb="x2">
<Text fontSize="small" fontWeight="bold">
Scroll target after pagination
</Text>
<Switcher selected={scrollTarget} onChange={setScrollTarget} aria-label="scroll target">
<Switch value="none">None</Switch>
<Switch value="topOfPage">Top of page</Switch>
<Switch value="topOfSection">Top of section</Switch>
</Switcher>
</Flex>
<Box height="180px" width="100%">
<Heading1 data-testid="page-heading">{messages[scrollTarget].page}</Heading1>
</Box>
<Box ref={ref} p="x4" height="1400px" width="100%" bg="lightBlue">
<Heading1 data-testid="section-heading">{messages[scrollTarget].section}</Heading1>
</Box>
<Pagination
scrollToTopAfterPagination={scrollTarget !== "none"}
scrollTargetRef={scrollTarget === "topOfSection" ? ref : undefined}
currentPage={currentPage}
totalPages={7}
onNext={() => setCurrentPage((p) => p + 1)}
onPrevious={() => setCurrentPage((p) => p - 1)}
onSelectPage={(page) => {
setCurrentPage(Number(page));
}}
/>
</Flex>
);
}
114 changes: 83 additions & 31 deletions src/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
};

const Pagination: React.FC<any> = (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 (
<Flex as="nav" aria-label={ariaLabel || t("pagination navigation")} {...restProps}>
<PreviousButton
disabled={currentPage === 1}
onClick={onPrevious}
onClick={() => {
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
<Text key={`sep${index}`} py="x1" mr="x2" fontSize="small" lineHeight="smallTextBase">
{SEPERATOR}
{SEPARATOR}
</Text>
);
else
Expand All @@ -62,18 +97,35 @@ const Pagination: React.FC<any> = (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}
</PageNumber>
);
})}
<NextButton disabled={currentPage === totalPages} onClick={onNext} ariaLabel={nextAriaLabel} label={nextLabel} />
<NextButton
disabled={currentPage === totalPages}
onClick={() => {
flushSync(() => {
onNext();
});

scrollToTop();
}}
ariaLabel={nextAriaLabel}
label={nextLabel}
/>
</Flex>
);
};
}

Pagination.propTypes = {
currentPage: PropTypes.number.isRequired,
Expand Down
5 changes: 3 additions & 2 deletions src/Pagination/PaginationButton.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
Expand Down
Loading
Loading