The tea shop's order history runs to many pages. Build Pagination and export it by name: it takes page (the current page, from 1), totalPages (at least 1), and onChange. It holds no state of its own: it shows the page it's given and calls onChange(newPage) when the user asks for another. The default export, App, is a small demo that keeps the page in state; the starter has it ready.
Render a <nav aria-label="Pagination"> holding a <ul>. Its first item is a Previous button and its last a Next button. Between them come the page buttons, each in its own <li>, with the page number as text and aria-label like Page 4.
Show the first page, the last page, the current page, and the pages right before and after it. Where pages are left out, show one <li> with the text … (the single ellipsis character, not a button). If only one page would be left out, show that page instead of an ellipsis. So page 5 of 20 shows 1 … 4 5 6 … 20, page 4 shows 1 2 3 4 5 … 20, and page 3 of 5 shows 1 2 3 4 5.
The current page's button has aria-current="page"; no other button has it. Clicking another page's button calls onChange with its number, and clicking the current one does nothing. Previous calls onChange(page - 1) and is disabled on page 1; Next calls onChange(page + 1) and is disabled on the last page.
<Pagination page={5} totalPages={20} onChange={setPage} />
// renders, item by item:
// Previous | 1 | … | 4 | 5 | 6 | … | 20 | Next
<li><button aria-label="Page 5" aria-current="page">5</button></li>
const items = () => within(screen.getByRole("navigation", { name: "Pagination" })).getAllByRole("listitem", { hidden: true }).map((li) => li.textContent);
render(<Pagination page={5} totalPages={20} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "…", "4", "5", "6", "…", "20", "Next"]);
const items = () => within(screen.getByRole("navigation", { name: "Pagination" })).getAllByRole("listitem", { hidden: true }).map((li) => li.textContent);
const { rerender } = render(<Pagination page={3} totalPages={20} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "2", "3", "4", "…", "20", "Next"]);
rerender(<Pagination page={19} totalPages={20} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "…", "18", "19", "20", "Next"]);
rerender(<Pagination page={1} totalPages={20} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "2", "…", "20", "Next"]);
const items = () => within(screen.getByRole("navigation", { name: "Pagination" })).getAllByRole("listitem", { hidden: true }).map((li) => li.textContent);
const { rerender } = render(<Pagination page={4} totalPages={20} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "2", "3", "4", "5", "…", "20", "Next"]);
rerender(<Pagination page={17} totalPages={20} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "…", "16", "17", "18", "19", "20", "Next"]);
const items = () => within(screen.getByRole("navigation", { name: "Pagination" })).getAllByRole("listitem", { hidden: true }).map((li) => li.textContent);
const { rerender } = render(<Pagination page={3} totalPages={5} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "2", "3", "4", "5", "Next"]);
rerender(<Pagination page={1} totalPages={2} onChange={spy()} />);
expect(items()).toEqual(["Previous", "1", "2", "Next"]);
render(<Pagination page={5} totalPages={20} onChange={spy()} />);
expect(screen.getByRole("button", { name: "Page 5" })).toHaveAttribute("aria-current", "page");
const marked = screen.getAllByRole("button").filter((button) => button.hasAttribute("aria-current"));
expect(marked).toHaveLength(1);
const onChange = spy();
render(<Pagination page={5} totalPages={20} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "Page 20" }));
expect(onChange).toHaveBeenCalledWith(20);
await user.click(screen.getByRole("button", { name: "Page 5" }));
expect(onChange).toHaveBeenCalledTimes(1);
const onChange = spy();
render(<Pagination page={5} totalPages={20} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "Previous" }));
expect(onChange).toHaveBeenCalledWith(4);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(onChange).toHaveBeenCalledWith(6);
const { rerender } = render(<Pagination page={1} totalPages={20} onChange={spy()} />);
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
rerender(<Pagination page={20} totalPages={20} onChange={spy()} />);
expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
rerender(<Pagination page={1} totalPages={1} onChange={spy()} />);
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
expect(screen.getAllByRole("button", { name: /^Page/ })).toHaveLength(1);
render(<App />);
await user.click(screen.getByRole("button", { name: "Page 6" }));
expect(screen.getByRole("button", { name: "Page 6" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Page 7" })).toBeInTheDocument();
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Pagination controls. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.