-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add Pagination component * Move page array function outside of component, temp disable tests * Add all controls to story * Add tests * Add docs * Follow interactive elements guidelines * Update docs * Change icon size for in-list link to rems * Remove unnecessary required props * Memoize range calculation * Remove unnecessary useEffect * Update breakpoint name * Merge collectionSize and pageSize into a single totalPages prop * Add comment * Use display none instead of visibility hidden * Sort props --------- Co-authored-by: Vincent Smedinga <[email protected]>
- Loading branch information
1 parent
25b1ea7
commit 0442f7b
Showing
12 changed files
with
449 additions
and
2 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
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,18 @@ | ||
# Pagination | ||
|
||
Pagination (in het Nederlands: paginering) is een navigatie-element onder een zoekresultatenlijst. Bij grote hoeveelheden zoekresultaten kan het duidelijker of functioneler zijn om de inhoud over meerdere pagina´s te verdelen. Paginering toont op welke zoekresultatenlijst de gebruiker zich bevindt en kan hiermee naar een andere zoekresultatenlijst navigeren. | ||
|
||
## Richtlijnen | ||
|
||
- Gebruik paginering alleen op een zoekresultatenpagina. | ||
- Voeg de paginering toe na de lijst met zoekresultaten. | ||
- Start een zoekresultatenpagina bovenaan de pagina na het veranderen van pagina. | ||
- De paginering kan gecombineerd worden met een teller bovenaan de pagina die “Pagina # van ##” aanduidt. | ||
- De paginering wordt niet getoond als er maar 1 pagina is. | ||
- Verwijs de gebruikers door naar de eerste pagina als ze een URL opgeven van een paginanummer dat niet (meer) bestaat. | ||
|
||
## Relevante WCAG regels | ||
|
||
- [WCAG 2.4.8](https://www.w3.org/TR/WCAG22/#location): geef aan waar de gebruiker is in een verzameling van pagina's (AAA). | ||
|
||
Pagination is een interactief element, hier gelden [de algemene eisen en richtlijnen voor interactieve elementen](https://amsterdam.github.io/design-system/?path=/docs/docs-designrichtlijnen-interactieve-elementen--docs) voor. |
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,75 @@ | ||
/** | ||
* @license EUPL-1.2+ | ||
* Copyright (c) 2023 Gemeente Amsterdam | ||
*/ | ||
|
||
@import "../../utils/breakpoint"; | ||
|
||
@mixin list-reset { | ||
list-style-type: none; | ||
margin-block: 0; | ||
padding-inline-start: 0; | ||
} | ||
|
||
.amsterdam-pagination__list { | ||
color: var(--amsterdam-pagination-color); | ||
display: flex; | ||
flex-wrap: wrap; | ||
font-family: var(--amsterdam-pagination-font-family); | ||
font-size: var(--amsterdam-pagination-narrow-font-size); | ||
font-weight: var(--amsterdam-pagination-font-weight); | ||
justify-content: center; | ||
line-height: var(--amsterdam-pagination-line-height); | ||
|
||
@include list-reset; | ||
|
||
@media screen and (width > $amsterdam-breakpoint-typography) { | ||
font-size: var(--amsterdam-pagination-wide-font-size); | ||
} | ||
} | ||
|
||
@mixin button-reset { | ||
all: unset; | ||
box-sizing: border-box; | ||
outline: revert; | ||
-webkit-text-size-adjust: 100%; | ||
} | ||
|
||
.amsterdam-pagination__button { | ||
/* The reset is included at the top of the block here, if you set it | ||
at the bottom `all: unset` overrides the `gap` property. */ | ||
@include button-reset; | ||
|
||
cursor: pointer; | ||
display: flex; | ||
gap: 0.5rem; | ||
outline-offset: var(--amsterdam-pagination-button-outline-offset); | ||
padding-inline: 0.75rem; | ||
text-decoration-thickness: 2px; | ||
text-underline-offset: 3px; | ||
touch-action: manipulation; | ||
|
||
&:hover { | ||
color: var(--amsterdam-pagination-button-hover-color); | ||
text-decoration: underline; | ||
} | ||
|
||
&:disabled { | ||
display: none; | ||
} | ||
|
||
// Override for icon size | ||
span.amsterdam-icon svg { | ||
height: 1rem; | ||
width: 1rem; | ||
} | ||
} | ||
|
||
.amsterdam-pagination__button--current { | ||
cursor: default; | ||
font-weight: var(--amsterdam-pagination-button-current-font-weight); | ||
|
||
&:hover { | ||
text-decoration: none; | ||
} | ||
} |
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,107 @@ | ||
import { fireEvent, render, screen } from '@testing-library/react' | ||
import { createRef, useState } from 'react' | ||
import { Pagination } from './Pagination' | ||
import '@testing-library/jest-dom' | ||
|
||
describe('Pagination', () => { | ||
it('renders', () => { | ||
const { container } = render(<Pagination totalPages={10} />) | ||
const component = container.querySelector(':only-child') | ||
expect(component).toBeInTheDocument() | ||
expect(component).toBeVisible() | ||
}) | ||
|
||
it('renders a design system BEM class name', () => { | ||
const { container } = render(<Pagination totalPages={10} />) | ||
const component = container.querySelector(':only-child') | ||
expect(component).toHaveClass('amsterdam-pagination') | ||
}) | ||
|
||
it('can have a additional class name', () => { | ||
const { container } = render(<Pagination totalPages={10} className="extra" />) | ||
const component = container.querySelector(':only-child') | ||
expect(component).toHaveClass('extra') | ||
expect(component).toHaveClass('amsterdam-pagination') | ||
}) | ||
|
||
it('should render all the pages when totalPages < maxVisiblePages', () => { | ||
render(<Pagination totalPages={6} maxVisiblePages={7} />) | ||
expect(screen.getAllByRole('listitem').length).toBe(8) // 6 + 2 buttons | ||
expect(screen.queryByTestId('lastSpacer')).not.toBeInTheDocument() | ||
expect(screen.queryByTestId('firstSpacer')).not.toBeInTheDocument() | ||
}) | ||
|
||
it('should render the pages including one (last) spacer when totalPages > maxVisiblePages', () => { | ||
render(<Pagination page={1} totalPages={10} maxVisiblePages={7} />) | ||
expect(screen.getAllByRole('listitem').length).toBe(8) // 6 + 2 buttons | ||
expect(screen.getByTestId('lastSpacer')).toBeInTheDocument() | ||
expect(screen.queryByTestId('firstSpacer')).not.toBeInTheDocument() | ||
}) | ||
|
||
it('should render the pages including the two spacers when totalPages > maxVisiblePages and current page > 4', () => { | ||
render(<Pagination page={6} totalPages={10} maxVisiblePages={7} />) | ||
expect(screen.getAllByRole('listitem').length).toBe(7) // 5 + 2 buttons | ||
expect(screen.getByTestId('lastSpacer')).toBeInTheDocument() | ||
expect(screen.getByTestId('firstSpacer')).toBeInTheDocument() | ||
}) | ||
|
||
it('should navigate to the next page when clicking on the "next" button', () => { | ||
const onPageChangeMock = jest.fn() | ||
render(<Pagination page={6} totalPages={10} onPageChange={onPageChangeMock} />) | ||
|
||
expect(onPageChangeMock).not.toHaveBeenCalled() | ||
expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('7')).not.toHaveAttribute('aria-current', 'true') | ||
|
||
fireEvent.click(screen.getByText('volgende')) | ||
|
||
expect(onPageChangeMock).toHaveBeenCalled() | ||
expect(screen.getByText('6')).not.toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('7')).toHaveAttribute('aria-current', 'true') | ||
}) | ||
|
||
it('should navigate to the previous page when clicking on the "previous" button', () => { | ||
const onPageChangeMock = jest.fn() | ||
render(<Pagination page={6} totalPages={10} onPageChange={onPageChangeMock} />) | ||
|
||
expect(onPageChangeMock).not.toHaveBeenCalled() | ||
expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('5')).not.toHaveAttribute('aria-current', 'true') | ||
|
||
fireEvent.click(screen.getByText('vorige')) | ||
|
||
expect(onPageChangeMock).toHaveBeenCalled() | ||
expect(screen.getByText('6')).not.toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('5')).toHaveAttribute('aria-current', 'true') | ||
}) | ||
|
||
it('should be working in a controlled state', () => { | ||
function ControlledComponent() { | ||
const [page, setPage] = useState(6) | ||
|
||
return <Pagination page={page} totalPages={10} onPageChange={setPage} /> | ||
} | ||
|
||
render(<ControlledComponent />) | ||
|
||
expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('5')).not.toHaveAttribute('aria-current', 'true') | ||
|
||
fireEvent.click(screen.getByText('vorige')) | ||
|
||
expect(screen.getByText('6')).not.toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('5')).toHaveAttribute('aria-current', 'true') | ||
|
||
fireEvent.click(screen.getByText('volgende')) | ||
|
||
expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true') | ||
expect(screen.getByText('5')).not.toHaveAttribute('aria-current', 'true') | ||
}) | ||
|
||
it('supports ForwardRef in React', () => { | ||
const ref = createRef<HTMLElement>() | ||
const { container } = render(<Pagination totalPages={10} ref={ref} />) | ||
const component = container.querySelector(':only-child') | ||
expect(ref.current).toBe(component) | ||
}) | ||
}) |
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,174 @@ | ||
/** | ||
* @license EUPL-1.2+ | ||
* Copyright (c) 2023 Gemeente Amsterdam | ||
*/ | ||
|
||
import { ChevronLeftIcon, ChevronRightIcon } from '@amsterdam/design-system-react-icons' | ||
import clsx from 'clsx' | ||
import { ForwardedRef, forwardRef, HTMLAttributes, useMemo, useState } from 'react' | ||
import { Icon } from '../Icon/Icon' | ||
|
||
export interface PaginationProps extends HTMLAttributes<HTMLElement> { | ||
/** | ||
* The maximum amount of pages shown. This has a lower limit of 5 | ||
*/ | ||
maxVisiblePages?: number | ||
/** | ||
* Callback triggered when interaction changes the page number. | ||
*/ | ||
// eslint-disable-next-line no-unused-vars | ||
onPageChange?: (page: number) => void | ||
/** | ||
* The current page number. | ||
*/ | ||
page?: number | ||
/** | ||
* The total number of pages. | ||
*/ | ||
totalPages: number | ||
} | ||
|
||
/** | ||
* This returns an array of the range, including spacers | ||
* | ||
* @example | ||
* currentPage = 4, totalPages = 7, maxVisiblePages = 7 | ||
* // returns [1, 2, 3, 4, 5, 6, 7] | ||
* | ||
* @example | ||
* currentPage = 5, totalPages = 100, maxVisiblePages = 7 | ||
* // returns [1, 'firstSpacer', 4, 5, 6, 'lastSpacer', 100] | ||
* | ||
* @example | ||
* currentPage = 97, totalPages = 100, maxVisiblePages = 7 | ||
* // returns [1, 'firstSpacer', 96, 97, 98, 99, 100] | ||
*/ | ||
|
||
function getRange(currentPage: number, totalPages: number, maxVisiblePages: number): Array<string | number> { | ||
// the total amount of visible pages is whatever's lower, totalPages or maxVisiblePages | ||
// maxVisiblePages has a lower limit of 5 | ||
const visiblePages = Math.min(totalPages, Math.max(maxVisiblePages, 5)) | ||
|
||
const min = 1 | ||
// the center part of the range starts with the current page minus half of the visible pages | ||
let centerStartPage = currentPage - Math.floor(visiblePages / 2) | ||
// centerStartPage has a lower limit of 1 | ||
centerStartPage = Math.max(centerStartPage, min) | ||
// centerStartPage has an upper limit of 1 plus total pages minus visible pages | ||
centerStartPage = Math.min(centerStartPage, min + totalPages - visiblePages) | ||
|
||
const pages = Array.from({ length: visiblePages }, (_el, i) => centerStartPage + i).reduce<Array<string | number>>( | ||
(acc, pageNr, index) => { | ||
if (index === 0 && pageNr !== 1) { | ||
return [1, 'firstSpacer'] | ||
} | ||
|
||
if (totalPages > visiblePages && index === visiblePages - 2 && currentPage < totalPages - 2) { | ||
return [...acc, 'lastSpacer', totalPages] | ||
} | ||
// Skip a number when spacer is already added | ||
if ((acc.includes('firstSpacer') && index === 1) || (acc.includes('lastSpacer') && index === visiblePages - 1)) { | ||
return acc | ||
} | ||
return [...acc, pageNr] | ||
}, | ||
[], | ||
) | ||
|
||
return pages | ||
} | ||
|
||
export const Pagination = forwardRef( | ||
( | ||
{ className, maxVisiblePages = 7, onPageChange, page = 1, totalPages, ...restProps }: PaginationProps, | ||
ref: ForwardedRef<HTMLElement>, | ||
) => { | ||
const [currentPage, setCurrentPage] = useState(page) | ||
|
||
// Get array of page numbers and / or spacers | ||
const range = useMemo( | ||
() => getRange(currentPage, totalPages, maxVisiblePages), | ||
[currentPage, totalPages, maxVisiblePages], | ||
) | ||
|
||
const onChangePage = (newPage: number) => { | ||
if (onPageChange !== undefined) { | ||
onPageChange(newPage) | ||
} | ||
setCurrentPage(newPage) | ||
} | ||
|
||
const onPrevious = () => { | ||
onChangePage(currentPage - 1) | ||
} | ||
|
||
const onNext = () => { | ||
onChangePage(currentPage + 1) | ||
} | ||
|
||
// Don't show pagination if you only have one page | ||
if (totalPages <= 1) { | ||
return null | ||
} | ||
|
||
return ( | ||
<nav {...restProps} className={clsx('amsterdam-pagination', className)} ref={ref} aria-label="Paginering"> | ||
<ol className="amsterdam-pagination__list"> | ||
<li> | ||
<button | ||
aria-label="Vorige pagina" | ||
className="amsterdam-pagination__button" | ||
disabled={currentPage === 1} | ||
onClick={onPrevious} | ||
type="button" | ||
> | ||
<Icon svg={ChevronLeftIcon} size="level-6" /> | ||
vorige | ||
</button> | ||
</li> | ||
{range.map((pageNumberOrSpacer) => | ||
typeof pageNumberOrSpacer === 'number' ? ( | ||
<li key={pageNumberOrSpacer}> | ||
<button | ||
aria-current={pageNumberOrSpacer === currentPage ? true : undefined} | ||
aria-label={ | ||
pageNumberOrSpacer === currentPage | ||
? `Pagina ${pageNumberOrSpacer}` | ||
: `Ga naar pagina ${pageNumberOrSpacer}` | ||
} | ||
className={clsx( | ||
'amsterdam-pagination__button', | ||
pageNumberOrSpacer === currentPage && 'amsterdam-pagination__button--current', | ||
)} | ||
onClick={() => pageNumberOrSpacer !== currentPage && onChangePage(pageNumberOrSpacer)} | ||
tabIndex={pageNumberOrSpacer === currentPage ? -1 : 0} | ||
type="button" | ||
> | ||
{pageNumberOrSpacer} | ||
</button> | ||
</li> | ||
) : ( | ||
<li key={pageNumberOrSpacer} aria-hidden data-testid={pageNumberOrSpacer}> | ||
{'\u2026'} | ||
</li> | ||
), | ||
)} | ||
<li> | ||
<button | ||
aria-label="Volgende pagina" | ||
className="amsterdam-pagination__button" | ||
disabled={currentPage === totalPages} | ||
onClick={onNext} | ||
type="button" | ||
> | ||
volgende | ||
<Icon svg={ChevronRightIcon} size="level-6" /> | ||
</button> | ||
</li> | ||
</ol> | ||
</nav> | ||
) | ||
}, | ||
) | ||
|
||
Pagination.displayName = 'Pagination' |
Oops, something went wrong.