diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx
new file mode 100644
index 0000000..9ed2f7e
--- /dev/null
+++ b/apps/demo/src/App.tsx
@@ -0,0 +1,15 @@
+import { Button, usePagination, Pagination } from "@openfun/cunningham-react";
+import React from "react";
+import { tokens } from "./cunningham-tokens";
+
+export const App = () => {
+ const pagination = usePagination({ defaultPage: 50, defaultPagesCount: 100 });
+ return (
+
+
Cunningham Demo.
+
+
Primary-500 color is {tokens.theme.colors["primary-500"]}
+
+
+ );
+};
diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx
index e03e1de..afd17ff 100644
--- a/apps/demo/src/main.tsx
+++ b/apps/demo/src/main.tsx
@@ -1,15 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.scss";
-import { Button } from "@openfun/cunningham-react";
-import { tokens } from "./cunningham-tokens";
+import { CunninghamProvider } from "@openfun/cunningham-react";
+import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
Cunningham Demo.
-
- Primary-500 color is {tokens.theme.colors["primary-500"]}
-
+
+
+
);
diff --git a/packages/react/src/components/Pagination/index.scss b/packages/react/src/components/Pagination/index.scss
new file mode 100644
index 0000000..c7c810a
--- /dev/null
+++ b/packages/react/src/components/Pagination/index.scss
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/packages/react/src/components/Pagination/index.spec.tsx b/packages/react/src/components/Pagination/index.spec.tsx
new file mode 100644
index 0000000..3d264ac
--- /dev/null
+++ b/packages/react/src/components/Pagination/index.spec.tsx
@@ -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("", () => {
+ const Wrapper = (params: Parameters[0]) => () => {
+ const pagination = usePagination(params);
+ return (
+
+
+
+ );
+ };
+
+ it("does not render pagination when pagesCount is not set", async () => {
+ const Component = Wrapper({ defaultPage: 1 });
+ render();
+ expect(document.querySelector(".c__pagination")).toBeNull();
+ });
+ it("does not render pagination when pagesCount = 1", async () => {
+ const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 1 });
+ render();
+ expect(document.querySelector(".c__pagination")).toBeNull();
+ });
+ it("renders pagination with 2 pages", async () => {
+ const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 2 });
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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" })
+ );
+ });
+});
diff --git a/packages/react/src/components/Pagination/index.stories.mdx b/packages/react/src/components/Pagination/index.stories.mdx
new file mode 100644
index 0000000..12ed574
--- /dev/null
+++ b/packages/react/src/components/Pagination/index.stories.mdx
@@ -0,0 +1,67 @@
+import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs';
+import { Pagination, usePagination } from './index';
+
+
+
+# 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.
+
+
+
+
+
+
+## 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
+
+
+
+
+### 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.
+
+
+
+### Set page programmatically
+
+You can also set the page programmatically, for example, if you want to use a query parameter to set the page.
+
+
+
+### Things to know
+
+- The pagination will never render if the number of pages is less than 2.
+
+## Props
+
+### `` component
+
+
+
+### `usePagination` hook
+
+
\ No newline at end of file
diff --git a/packages/react/src/components/Pagination/index.stories.tsx b/packages/react/src/components/Pagination/index.stories.tsx
new file mode 100644
index 0000000..f24f47d
--- /dev/null
+++ b/packages/react/src/components/Pagination/index.stories.tsx
@@ -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;
+
+export const Basic = () => {
+ const pagination = usePagination({
+ defaultPagesCount: 100,
+ defaultPage: 50,
+ });
+ return (
+
+
+
+ );
+};
+
+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([]);
+ 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 (
+
+
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
+ );
+};
+
+export const ForcePage = () => {
+ const pagination = usePagination({
+ defaultPagesCount: 10,
+ });
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ pagination.setPage(5);
+ }, 500);
+ return () => {
+ clearTimeout(timeout);
+ };
+ }, []);
+ return (
+
+
+
+ );
+};
diff --git a/packages/react/src/components/Pagination/index.tsx b/packages/react/src/components/Pagination/index.tsx
new file mode 100644
index 0000000..977ff93
--- /dev/null
+++ b/packages/react/src/components/Pagination/index.tsx
@@ -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 (
+
+
+
+
+
+ {t("components.pagination.goto_label")}
+
+
+
+
+ );
+};
diff --git a/packages/react/src/components/Pagination/utils.tsx b/packages/react/src/components/Pagination/utils.tsx
new file mode 100644
index 0000000..d3bc640
--- /dev/null
+++ b/packages/react/src/components/Pagination/utils.tsx
@@ -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);
+ }
+ });
+};
diff --git a/packages/react/src/index.scss b/packages/react/src/index.scss
index 0054f81..06cdc1c 100644
--- a/packages/react/src/index.scss
+++ b/packages/react/src/index.scss
@@ -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);
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 368e96f..33c42a3 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,4 +1,5 @@
import "./index.scss";
export * from "./components/Button";
+export * from "./components/Pagination";
export * from "./components/Provider";
diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts
index e66d60c..4cbf663 100644
--- a/packages/react/vite.config.ts
+++ b/packages/react/vite.config.ts
@@ -30,6 +30,7 @@ export default defineConfig({
environment: "jsdom",
reporters: "verbose",
globals: true,
+ watchExclude: ["**/cunningham-tokens.js"],
coverage: {
all: true,
include: ["src/**/*.{ts,tsx}"],