✨(react) add Pagination component
In order to create a DataGrid we first need a fully working pagination component. It comes with multiples working examples in the documentation.
This commit is contained in:
19
packages/react/src/components/Pagination/index.scss
Normal file
19
packages/react/src/components/Pagination/index.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.c__pagination {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px var(--c--theme--colors--greyscale-200) solid;
|
||||
border-radius: 2px;
|
||||
padding: var(--c--theme--spacings--st);
|
||||
background: var(--c--theme--colors--greyscale-000);
|
||||
}
|
||||
|
||||
&__goto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
204
packages/react/src/components/Pagination/index.spec.tsx
Normal file
204
packages/react/src/components/Pagination/index.spec.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from "react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { fireEvent } from "@testing-library/dom";
|
||||
import { Pagination, usePagination } from "components/Pagination/index";
|
||||
import { expectPaginationList } from "components/Pagination/utils";
|
||||
import { CunninghamProvider } from "components/Provider";
|
||||
|
||||
describe("<Pagination/>", () => {
|
||||
const Wrapper = (params: Parameters<typeof usePagination>[0]) => () => {
|
||||
const pagination = usePagination(params);
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<Pagination {...pagination} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it("does not render pagination when pagesCount is not set", async () => {
|
||||
const Component = Wrapper({ defaultPage: 1 });
|
||||
render(<Component />);
|
||||
expect(document.querySelector(".c__pagination")).toBeNull();
|
||||
});
|
||||
it("does not render pagination when pagesCount = 1", async () => {
|
||||
const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 1 });
|
||||
render(<Component />);
|
||||
expect(document.querySelector(".c__pagination")).toBeNull();
|
||||
});
|
||||
it("renders pagination with 2 pages", async () => {
|
||||
const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 2 });
|
||||
render(<Component />);
|
||||
expect(document.querySelector(".c__pagination")).toBeDefined();
|
||||
expectPaginationList([
|
||||
{ text: "navigate_before", name: "Go to previous page" },
|
||||
{ text: "1", name: "You are currently on page 1" },
|
||||
{ text: "2", name: "Go to page 2" },
|
||||
{ text: "navigate_next", name: "Go to next page" },
|
||||
]);
|
||||
});
|
||||
it("renders pagination with 10 pages", async () => {
|
||||
const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 10 });
|
||||
render(<Component />);
|
||||
expect(document.querySelector(".c__pagination")).toBeDefined();
|
||||
expectPaginationList([
|
||||
{ text: "navigate_before", name: "Go to previous page" },
|
||||
{ text: "1", name: "You are currently on page 1" },
|
||||
{ text: "2", name: "Go to page 2" },
|
||||
{ text: "3", name: "Go to page 3" },
|
||||
{ text: "..." },
|
||||
{ text: "10", name: "Go to page 10" },
|
||||
{ text: "navigate_next", name: "Go to next page" },
|
||||
]);
|
||||
});
|
||||
it("renders pagination with 100 pages with current = 50", async () => {
|
||||
const Component = Wrapper({ defaultPage: 50, defaultPagesCount: 100 });
|
||||
render(<Component />);
|
||||
expect(document.querySelector(".c__pagination")).toBeDefined();
|
||||
expectPaginationList([
|
||||
{ text: "navigate_before", name: "Go to previous page" },
|
||||
{ text: "1", name: "Go to page 1" },
|
||||
{ text: "..." },
|
||||
{ text: "48", name: "Go to page 48" },
|
||||
{ text: "49", name: "Go to page 49" },
|
||||
{ text: "50", name: "You are currently on page 50" },
|
||||
{ text: "51", name: "Go to page 51" },
|
||||
{ text: "52", name: "Go to page 52" },
|
||||
{ text: "..." },
|
||||
{ text: "100", name: "Go to page 100" },
|
||||
{ text: "navigate_next", name: "Go to next page" },
|
||||
]);
|
||||
});
|
||||
it("navigates next and previous", async () => {
|
||||
// Verify that next and previous can be disabled
|
||||
const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 3 });
|
||||
render(<Component />);
|
||||
expect(document.querySelector(".c__pagination")).toBeDefined();
|
||||
|
||||
// Current page = 1
|
||||
expectPaginationList([
|
||||
{ text: "navigate_before", name: "Go to previous page", disabled: true },
|
||||
{ text: "1", name: "You are currently on page 1" },
|
||||
{ text: "2", name: "Go to page 2" },
|
||||
{ text: "3", name: "Go to page 3" },
|
||||
{ text: "navigate_next", name: "Go to next page", disabled: false },
|
||||
]);
|
||||
|
||||
const nextButton = screen.getByRole("button", {
|
||||
name: "Go to next page",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
// Go to page 2.
|
||||
user.click(nextButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expectPaginationList([
|
||||
{
|
||||
text: "navigate_before",
|
||||
name: "Go to previous page",
|
||||
disabled: false,
|
||||
},
|
||||
{ text: "1", name: "Go to page 1" },
|
||||
{ text: "2", name: "You are currently on page 2" },
|
||||
{ text: "3", name: "Go to page 3" },
|
||||
{ text: "navigate_next", name: "Go to next page", disabled: false },
|
||||
])
|
||||
);
|
||||
|
||||
// Go to page 3.
|
||||
user.click(nextButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expectPaginationList([
|
||||
{
|
||||
text: "navigate_before",
|
||||
name: "Go to previous page",
|
||||
disabled: false,
|
||||
},
|
||||
{ text: "1", name: "Go to page 1" },
|
||||
{ text: "2", name: "Go to page 2" },
|
||||
{ text: "3", name: "You are currently on page 3" },
|
||||
{ text: "navigate_next", name: "Go to next page", disabled: true },
|
||||
])
|
||||
);
|
||||
|
||||
const previousButton = screen.getByRole("button", {
|
||||
name: "Go to previous page",
|
||||
});
|
||||
|
||||
// Go to page 2.
|
||||
user.click(previousButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expectPaginationList([
|
||||
{
|
||||
text: "navigate_before",
|
||||
name: "Go to previous page",
|
||||
disabled: false,
|
||||
},
|
||||
{ text: "1", name: "Go to page 1" },
|
||||
{ text: "2", name: "You are currently on page 2" },
|
||||
{ text: "3", name: "Go to page 3" },
|
||||
{ text: "navigate_next", name: "Go to next page", disabled: false },
|
||||
])
|
||||
);
|
||||
|
||||
// Go to page 1.
|
||||
user.click(previousButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expectPaginationList([
|
||||
{
|
||||
text: "navigate_before",
|
||||
name: "Go to previous page",
|
||||
disabled: true,
|
||||
},
|
||||
{ text: "1", name: "You are currently on page 1" },
|
||||
{ text: "2", name: "Go to page 2" },
|
||||
{ text: "3", name: "Go to page 3" },
|
||||
{ text: "navigate_next", name: "Go to next page", disabled: false },
|
||||
])
|
||||
);
|
||||
});
|
||||
it("can goto page", async () => {
|
||||
const Component = Wrapper({ defaultPage: 50, defaultPagesCount: 100 });
|
||||
render(<Component />);
|
||||
screen.getByRole("button", { name: "You are currently on page 50" });
|
||||
const input = screen.getByRole("spinbutton", { name: "Go to any page" });
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Go to page 60.
|
||||
await act(async () => {
|
||||
await user.type(input, "60");
|
||||
// We cannot use `user.type(input, "60{enter}")` due to the following bug: https://github.com/testing-library/user-event/issues/1074.
|
||||
fireEvent.submit(input);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
screen.getByRole("button", { name: "You are currently on page 60" })
|
||||
);
|
||||
|
||||
// Try to go to page > 100 and verify that it goes to 100.
|
||||
await act(async () => {
|
||||
await user.type(input, "110");
|
||||
// We cannot use `user.type(input, "60{enter}")` due to the following bug: https://github.com/testing-library/user-event/issues/1074.
|
||||
fireEvent.submit(input);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
screen.getByRole("button", { name: "You are currently on page 100" })
|
||||
);
|
||||
|
||||
// Try to go to page < 1 and verify that it goes to 1.
|
||||
await act(async () => {
|
||||
await user.type(input, "-10");
|
||||
// We cannot use `user.type(input, "60{enter}")` due to the following bug: https://github.com/testing-library/user-event/issues/1074.
|
||||
fireEvent.submit(input);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
screen.getByRole("button", { name: "You are currently on page 1" })
|
||||
);
|
||||
});
|
||||
});
|
||||
67
packages/react/src/components/Pagination/index.stories.mdx
Normal file
67
packages/react/src/components/Pagination/index.stories.mdx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs';
|
||||
import { Pagination, usePagination } from './index';
|
||||
|
||||
<Meta title="Components/Pagination/Doc" component={Pagination}/>
|
||||
|
||||
# Pagination
|
||||
|
||||
The Pagination component can be used anywhere you have some data you want to split between pages, you can use it
|
||||
for synchronous loading as well as asynchronous loading. You can paginate your already loaded data, but you can also
|
||||
fetch it from a server, the component is really versatile.
|
||||
|
||||
|
||||
<Canvas withSource={"none"}>
|
||||
<Story id="components-pagination--basic"/>
|
||||
</Canvas>
|
||||
|
||||
<Source
|
||||
language='ts'
|
||||
dark
|
||||
format={false}
|
||||
code={`import { Pagination, usePagination } from "@openfun/cunningham-react";`}
|
||||
/>
|
||||
|
||||
## Usage
|
||||
|
||||
The Pagination component comes with a hook called `usePagination` that handles the logic behind it. Pagination is a
|
||||
controlled component, so, to make it more handy we provide you this hook.
|
||||
|
||||
The most basic usage you can make of it is this one, defining a pagination with 10 pages.
|
||||
|
||||
### Basic
|
||||
|
||||
<Canvas withSource="open">
|
||||
<Story id="components-pagination--basic"/>
|
||||
</Canvas>
|
||||
|
||||
|
||||
### List of items
|
||||
|
||||
But this won't make you really happy if you want to paginate your list of items, so you can wire things a bit better.
|
||||
Let's make a component that paginate a list of random number.
|
||||
|
||||
<Canvas withSource="open">
|
||||
<Story id="components-pagination--list"/>
|
||||
</Canvas>
|
||||
|
||||
### Set page programmatically
|
||||
|
||||
You can also set the page programmatically, for example, if you want to use a query parameter to set the page.
|
||||
|
||||
<Canvas withSource="open">
|
||||
<Story id="components-pagination--force-page"/>
|
||||
</Canvas>
|
||||
|
||||
### Things to know
|
||||
|
||||
- The pagination will never render if the number of pages is less than 2.
|
||||
|
||||
## Props
|
||||
|
||||
### `<Pagination/>` component
|
||||
|
||||
<ArgsTable of={Pagination} />
|
||||
|
||||
### `usePagination` hook
|
||||
|
||||
<ArgsTable of={usePagination} />
|
||||
84
packages/react/src/components/Pagination/index.stories.tsx
Normal file
84
packages/react/src/components/Pagination/index.stories.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ComponentMeta } from "@storybook/react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Pagination, usePagination } from "components/Pagination/index";
|
||||
import { CunninghamProvider } from "components/Provider";
|
||||
|
||||
export default {
|
||||
title: "Components/Pagination",
|
||||
component: Pagination,
|
||||
} as ComponentMeta<typeof Pagination>;
|
||||
|
||||
export const Basic = () => {
|
||||
const pagination = usePagination({
|
||||
defaultPagesCount: 100,
|
||||
defaultPage: 50,
|
||||
});
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<Pagination {...pagination} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const List = () => {
|
||||
// Numbers from 0 to 99.
|
||||
const database = useMemo(() => Array.from(Array(100).keys()), []);
|
||||
// Items to display on the current page.
|
||||
const [items, setItems] = useState<number[]>([]);
|
||||
const pagination = usePagination({ pageSize: 10 });
|
||||
|
||||
// On page change.
|
||||
useEffect(() => {
|
||||
// Simulate a HTTP request delay.
|
||||
const timeout = setTimeout(() => {
|
||||
// Sets the number of pages based on the number of items in the database.
|
||||
pagination.setPagesCount(
|
||||
Math.ceil(database.length / pagination.pageSize)
|
||||
);
|
||||
// Sets the items to display on the current page.
|
||||
setItems(
|
||||
database.slice(
|
||||
(pagination.page - 1) * pagination.pageSize,
|
||||
pagination.page * pagination.pageSize
|
||||
)
|
||||
);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [pagination.page]);
|
||||
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<div>
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<div className="p-t bg-secondary-300 mb-t" key={item}>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const ForcePage = () => {
|
||||
const pagination = usePagination({
|
||||
defaultPagesCount: 10,
|
||||
});
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
pagination.setPage(5);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<Pagination {...pagination} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
};
|
||||
171
packages/react/src/components/Pagination/index.tsx
Normal file
171
packages/react/src/components/Pagination/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { Button } from "components/Button";
|
||||
import { Input } from "components/Forms/Input";
|
||||
import { useCunningham } from "components/Provider";
|
||||
|
||||
export interface PaginationProps {
|
||||
/** Current page */
|
||||
page: number;
|
||||
/** Called when page need to change */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Total number of pages */
|
||||
pagesCount?: number;
|
||||
/** Total number of items per page */
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const usePagination = ({
|
||||
defaultPage = 1,
|
||||
defaultPagesCount,
|
||||
pageSize = 10,
|
||||
}: {
|
||||
/** Default current page */
|
||||
defaultPage?: number;
|
||||
/** Default total number of pages */
|
||||
defaultPagesCount?: number;
|
||||
/** Total number of items per page */
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const [page, setPage] = useState(defaultPage);
|
||||
const [pagesCount, setPagesCount] = useState(defaultPagesCount);
|
||||
return {
|
||||
page,
|
||||
setPage,
|
||||
onPageChange: setPage,
|
||||
pagesCount,
|
||||
setPagesCount,
|
||||
pageSize,
|
||||
};
|
||||
};
|
||||
|
||||
export const Pagination = ({
|
||||
page,
|
||||
onPageChange,
|
||||
pagesCount = 0,
|
||||
}: PaginationProps) => {
|
||||
const { t } = useCunningham();
|
||||
const [gotoValue, setGotoValue] = useState("");
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the default list of all the page numbers we intend to show
|
||||
const pageList = [
|
||||
1,
|
||||
// If there is just one page between first page and currentPage - 2,
|
||||
// we can display this page number instead of "..."
|
||||
page - 2 === 3 ? page - 3 : -1,
|
||||
page - 2,
|
||||
page - 1,
|
||||
page,
|
||||
page + 1,
|
||||
page + 2,
|
||||
// If there is just one page between maxPage and currentPage + 2,
|
||||
// we can display this page number instead of "..."
|
||||
page + 3 === pagesCount - 1 ? page + 3 : -1,
|
||||
pagesCount,
|
||||
]
|
||||
// Filter out page numbers below 1 (when currentPage is 1 or 2)
|
||||
.filter((_page) => _page > 0)
|
||||
// Filter out page numbers above the max (they do not have anything to display)
|
||||
.filter((_page) => _page <= pagesCount)
|
||||
// Drop duplicates (this is trivial as our pageList is sorted)
|
||||
.filter((_page, index, list) => _page !== list[index - 1]);
|
||||
|
||||
const onPreviousClick = () => {
|
||||
onPageChange(Math.max(page - 1, 0));
|
||||
};
|
||||
const onNextClick = () => {
|
||||
onPageChange(Math.min(page + 1, pagesCount));
|
||||
};
|
||||
|
||||
const gotoPage = () => {
|
||||
let value = +gotoValue;
|
||||
if (value < 0) {
|
||||
value = 1;
|
||||
}
|
||||
if (value > pagesCount) {
|
||||
value = pagesCount;
|
||||
}
|
||||
onPageChange(value);
|
||||
setGotoValue("");
|
||||
};
|
||||
|
||||
const canPrevious = page > 1;
|
||||
const canNext = page < pagesCount;
|
||||
|
||||
return (
|
||||
<div className="c__pagination">
|
||||
<div className="c__pagination__list">
|
||||
<Button
|
||||
color="tertiary"
|
||||
aria-label={t("components.pagination.previous_aria")}
|
||||
onClick={onPreviousClick}
|
||||
disabled={!canPrevious}
|
||||
icon={<span className="material-icons">navigate_before</span>}
|
||||
size="small"
|
||||
/>
|
||||
{pageList.map((_page, index) => (
|
||||
<Fragment key={_page}>
|
||||
{/* Prepend a cell with "..." when the page number we're rendering does not follow the previous one */}
|
||||
{_page > (pageList[index - 1] || 0) + 1 && <span>...</span>}
|
||||
{_page === page ? (
|
||||
<Button
|
||||
color="tertiary"
|
||||
active={true}
|
||||
aria-label={t("components.pagination.current_page_aria", {
|
||||
page: _page,
|
||||
})}
|
||||
size="small"
|
||||
>
|
||||
{_page}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="tertiary"
|
||||
aria-label={t("components.pagination.goto_page_aria", {
|
||||
page: _page,
|
||||
})}
|
||||
onClick={() => onPageChange(_page)}
|
||||
size="small"
|
||||
>
|
||||
{_page}
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
<Button
|
||||
color="tertiary"
|
||||
aria-label={t("components.pagination.next_aria")}
|
||||
onClick={onNextClick}
|
||||
disabled={!canNext}
|
||||
icon={<span className="material-icons">navigate_next</span>}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="c__pagination__goto">
|
||||
<div className="fs-m clr-greyscale-700">
|
||||
{t("components.pagination.goto_label")}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
gotoPage();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label={t("components.pagination.goto_label_aria")}
|
||||
size={2}
|
||||
value={gotoValue}
|
||||
onChange={(e) => setGotoValue(e.target.value)}
|
||||
min={1}
|
||||
max={pagesCount}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
packages/react/src/components/Pagination/utils.tsx
Normal file
16
packages/react/src/components/Pagination/utils.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export const expectPaginationList = (
|
||||
expectations: { text: string; name?: string; disabled?: boolean }[]
|
||||
) => {
|
||||
const buttons = document.querySelectorAll(".c__pagination__list > *");
|
||||
expect(buttons.length).toEqual(expectations.length);
|
||||
buttons.forEach((button, k) => {
|
||||
const expected = expectations[k];
|
||||
if (expected.name) {
|
||||
expect(button.getAttribute("aria-label")).toEqual(expected.name);
|
||||
}
|
||||
expect(button.textContent).toEqual(expected.text);
|
||||
if (expected.disabled !== undefined) {
|
||||
expect(button.hasAttribute("disabled")).toBe(expected.disabled);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
@import '@openfun/cunningham-tokens/default-tokens';
|
||||
@import './components/Button';
|
||||
@import './components/Forms/Input';
|
||||
@import './components/Pagination';
|
||||
|
||||
* {
|
||||
font-family: var(--c--theme--font--families--base);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./index.scss";
|
||||
|
||||
export * from "./components/Button";
|
||||
export * from "./components/Pagination";
|
||||
export * from "./components/Provider";
|
||||
|
||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
reporters: "verbose",
|
||||
globals: true,
|
||||
watchExclude: ["**/cunningham-tokens.js"],
|
||||
coverage: {
|
||||
all: true,
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
|
||||
Reference in New Issue
Block a user