generated from textbook/starter-kit
-
-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
22 changed files
with
620 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
nav[aria-label="pagination"] { | ||
align-items: center; | ||
display: flex; | ||
justify-content: flex-end; | ||
max-width: 720px; | ||
margin: 0 auto; | ||
width: 100%; | ||
|
||
& > ul { | ||
display: flex; | ||
flex-direction: row; | ||
list-style-type: none; | ||
margin: 0; | ||
padding: 0; | ||
|
||
li { | ||
> * { | ||
border: 2px solid #1f891f; | ||
border-radius: 5px; | ||
color: #1f891f; | ||
padding: 2px 8px; | ||
margin: 0 8px; | ||
} | ||
|
||
> span { | ||
border-color: #88e488; | ||
color: #88e488; | ||
cursor: not-allowed; | ||
} | ||
|
||
> a { | ||
text-decoration: none; | ||
|
||
&:hover, | ||
&:focus { | ||
background-color: #e8fae8; | ||
} | ||
|
||
&:active { | ||
background-color: #d8f6d8; | ||
} | ||
|
||
&.active { | ||
background-color: #1f891f; | ||
color: white; | ||
|
||
&:hover, | ||
&:focus { | ||
background-color: #1b791b; | ||
border-color: #1b791b; | ||
} | ||
|
||
&:active { | ||
background-color: #186918; | ||
border-color: #186918; | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
label { | ||
margin-left: 16px; | ||
|
||
& > select { | ||
background-color: white; | ||
border: 1px solid #bdbdbd; | ||
border-radius: 5px; | ||
box-sizing: border-box; | ||
font: | ||
400 16px "Lato", | ||
sans-serif; | ||
margin-left: 16px; | ||
padding: 8px; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import { render, screen, within } from "@testing-library/react"; | ||
import userEvent from "@testing-library/user-event"; | ||
import { MemoryRouter, useLocation } from "react-router-dom"; | ||
|
||
import Pagination from "./index"; | ||
|
||
describe("Pagination", () => { | ||
it("renders a link for each page", () => { | ||
renderInRouter(3, { page: 2 }); | ||
const page1 = screen.getByRole("link", { name: /page 1 \(first page\)/i }); | ||
const page2 = screen.getByRole("link", { name: /page 2/i }); | ||
const page3 = screen.getByRole("link", { name: /page 3 \(last page\)/i }); | ||
expect(page1).toHaveAttribute("href", "/test"); | ||
expect(page1).not.toHaveClass("active"); | ||
expect(page2).toHaveAttribute("href", "/test?page=2"); | ||
expect(page2).toHaveClass("active"); | ||
expect(page2).toHaveAttribute("aria-current", "page"); | ||
expect(page3).toHaveAttribute("href", "/test?page=3"); | ||
expect(page3).not.toHaveClass("active"); | ||
}); | ||
|
||
it("renders links for next and previous pages", () => { | ||
renderInRouter(3, { page: 2 }); | ||
expect( | ||
screen.getByRole("link", { name: /previous page/i }) | ||
).toHaveAttribute("href", "/test"); | ||
expect(screen.getByRole("link", { name: /next page/i })).toHaveAttribute( | ||
"href", | ||
"/test?page=3" | ||
); | ||
}); | ||
|
||
it("does not show previous link on first page", () => { | ||
renderInRouter(3, { page: 1 }); | ||
expect( | ||
screen.queryByRole("link", { name: /previous page/i }) | ||
).not.toBeInTheDocument(); | ||
}); | ||
|
||
it("does not show next link on last page", () => { | ||
renderInRouter(3, { page: 3 }); | ||
expect( | ||
screen.queryByRole("link", { name: /next page/i }) | ||
).not.toBeInTheDocument(); | ||
}); | ||
|
||
it("shows page size options", () => { | ||
const expectedSizes = ["10", "20", "50"]; | ||
renderInRouter(3, { perPage: 10 }); | ||
const pageSizeSelect = screen.getByRole("combobox", { | ||
name: /items per page/i, | ||
}); | ||
expect(pageSizeSelect).toHaveValue("10"); | ||
expect(within(pageSizeSelect).getAllByRole("option")).toHaveLength( | ||
expectedSizes.length | ||
); | ||
expectedSizes.forEach((size) => { | ||
expect( | ||
within(pageSizeSelect).getByRole("option", { name: size }) | ||
).toHaveValue(size); | ||
}); | ||
}); | ||
|
||
it("reset page when page size changes", async () => { | ||
const user = userEvent.setup(); | ||
renderInRouter(3, { page: 2 }); | ||
await user.selectOptions( | ||
screen.getByRole("combobox", { name: /items per page/i }), | ||
"50" | ||
); | ||
expect(screen.getByRole("link", { name: /current page/i })).toHaveAttribute( | ||
"href", | ||
"/test?perPage=50" | ||
); | ||
}); | ||
}); | ||
|
||
function RevealLocation() { | ||
const { pathname, search } = useLocation(); | ||
return <a href={`${pathname}${search}`}>Current page</a>; | ||
} | ||
|
||
const renderInRouter = (lastPage = 3, searchParams = {}) => | ||
render( | ||
<MemoryRouter | ||
initialEntries={[`/test?${new URLSearchParams(searchParams)}`]} | ||
> | ||
<RevealLocation /> | ||
<Pagination lastPage={lastPage} /> | ||
</MemoryRouter> | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import clsx from "clsx"; | ||
import PropTypes from "prop-types"; | ||
import { useCallback } from "react"; | ||
import { Link, useLocation, useNavigate } from "react-router-dom"; | ||
|
||
import { useSearchParams } from "../../hooks"; | ||
|
||
import "./Pagination.scss"; | ||
|
||
const DEFAULT_PAGINATION = { page: 1, perPage: 20 }; | ||
|
||
const mergeWithoutDefaults = (...objects) => | ||
Object.entries(Object.assign({}, ...objects)).filter( | ||
([key, value]) => DEFAULT_PAGINATION[key] !== value | ||
); | ||
|
||
/** | ||
* Based on {@link https://design-system.w3.org/components/pagination.html W3C design system}. | ||
* @param {number} lastPage | ||
*/ | ||
export default function Pagination({ lastPage }) { | ||
const location = useLocation(); | ||
const navigate = useNavigate(); | ||
const searchParams = useSearchParams(DEFAULT_PAGINATION); | ||
|
||
const createRoute = useCallback( | ||
(update) => { | ||
return `${location.pathname}?${new URLSearchParams( | ||
mergeWithoutDefaults(searchParams, update) | ||
)}`; | ||
}, | ||
[location, searchParams] | ||
); | ||
|
||
return ( | ||
<nav aria-label="pagination"> | ||
<ul> | ||
<li> | ||
{searchParams.page !== 1 ? ( | ||
<Link to={createRoute({ page: searchParams.page - 1 })}> | ||
<span aria-hidden><</span> | ||
<span className="visuallyhidden">previous page</span> | ||
</Link> | ||
) : ( | ||
<span aria-hidden><</span> | ||
)} | ||
</li> | ||
{[...new Array(lastPage)].map((_, index) => ( | ||
<li key={index}> | ||
<PageLink | ||
currentPage={searchParams.page} | ||
page={index + 1} | ||
lastPage={lastPage} | ||
to={createRoute({ page: index + 1 })} | ||
/> | ||
</li> | ||
))} | ||
<li> | ||
{searchParams.page !== lastPage ? ( | ||
<Link to={createRoute({ page: searchParams.page + 1 })}> | ||
<span aria-hidden>></span> | ||
<span className="visuallyhidden">Next page</span> | ||
</Link> | ||
) : ( | ||
<span aria-hidden>></span> | ||
)} | ||
</li> | ||
</ul> | ||
<label> | ||
Items per page | ||
<select | ||
onChange={({ target: { value } }) => | ||
navigate(createRoute({ page: 1, perPage: parseInt(value, 10) })) | ||
} | ||
value={searchParams.perPage} | ||
> | ||
{[10, 20, 50].map((n) => ( | ||
<option key={n} value={n}> | ||
{n} | ||
</option> | ||
))} | ||
</select> | ||
</label> | ||
</nav> | ||
); | ||
} | ||
|
||
Pagination.propTypes = { | ||
lastPage: PropTypes.number.isRequired, | ||
}; | ||
|
||
function PageLink({ currentPage, lastPage, page, to }) { | ||
return ( | ||
<Link | ||
aria-current={page === currentPage && "page"} | ||
className={clsx(page === currentPage && "active")} | ||
to={to} | ||
> | ||
<span className="visuallyhidden">Page </span> | ||
{page} | ||
{lastPage > 1 && page === 1 && ( | ||
<span className="visuallyhidden">(first page)</span> | ||
)} | ||
{lastPage > 1 && page === lastPage && ( | ||
<span className="visuallyhidden">(last page)</span> | ||
)} | ||
</Link> | ||
); | ||
} | ||
|
||
PageLink.propTypes = { | ||
currentPage: PropTypes.number.isRequired, | ||
lastPage: PropTypes.number.isRequired, | ||
page: PropTypes.number.isRequired, | ||
to: PropTypes.string.isRequired, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
export { default as Button } from "./Button"; | ||
export { default as Form, FormControls } from "./Form"; | ||
export { default as Header } from "./Header"; | ||
export { default as Pagination } from "./Pagination"; | ||
export { default as ResourceList } from "./ResourceList"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default as useSearchParams } from "./useSearchParams"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { useMemo, useState } from "react"; | ||
import { useLocation } from "react-router-dom"; | ||
|
||
const INTEGER = /^\d+$/; | ||
|
||
/** | ||
* Expose the current URL search/query parameters as an object. | ||
* @param {Record<string, string | number>} defaults | ||
* @returns {Record<string, string | number>} | ||
*/ | ||
export default function useSearchParams(defaults = {}) { | ||
const { search } = useLocation(); | ||
const [initial] = useState(defaults); | ||
|
||
return useMemo(() => { | ||
const searchParams = new URLSearchParams(search); | ||
return { | ||
...initial, | ||
...Object.fromEntries( | ||
[...searchParams.entries()].map(([key, value]) => [ | ||
key, | ||
INTEGER.test(value) ? parseInt(value, 10) : value, | ||
]) | ||
), | ||
}; | ||
}, [initial, search]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { renderHook } from "@testing-library/react"; | ||
import { MemoryRouter } from "react-router-dom"; | ||
|
||
import { useSearchParams } from "./index"; | ||
|
||
describe("useSearchParams", () => { | ||
it("exposes current location query parameters as an object", () => { | ||
const { current } = renderOnRoute("/page?foo=bar&baz=qux"); | ||
expect(current).toEqual({ foo: "bar", baz: "qux" }); | ||
}); | ||
|
||
it("parses numerical values", () => { | ||
const { current } = renderOnRoute("/page?foo=123&baz=456"); | ||
expect(current).toEqual({ foo: 123, baz: 456 }); | ||
}); | ||
|
||
it("includes the specified defaults", () => { | ||
const { current } = renderOnRoute("/page?bar=456&baz=used", () => | ||
useSearchParams({ foo: 123, baz: "overridden" }) | ||
); | ||
expect(current).toEqual({ foo: 123, bar: 456, baz: "used" }); | ||
}); | ||
}); | ||
|
||
function renderOnRoute(route, hook = useSearchParams) { | ||
const { result } = renderHook(hook, { | ||
wrapper: ({ children }) => ( | ||
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter> | ||
), | ||
}); | ||
return result; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.