Skip to content

Commit

Permalink
Add pagination component (#674)
Browse files Browse the repository at this point in the history
* 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
alimpens and VincentSmedinga authored Nov 9, 2023
1 parent 25b1ea7 commit 0442f7b
Show file tree
Hide file tree
Showing 12 changed files with 449 additions and 2 deletions.
1 change: 1 addition & 0 deletions packages/css/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
@import "./pagination/pagination";
@import "./accordion/accordion";
@import "./alert/alert";
@import "./aspect-ratio/aspect-ratio";
Expand Down
4 changes: 2 additions & 2 deletions packages/css/src/link/link.scss
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@
// Override for icon size
.amsterdam-link--in-list__chevron {
span.amsterdam-icon svg {
height: 16px;
width: 16px;
height: 1rem;
width: 1rem;
}
}

Expand Down
18 changes: 18 additions & 0 deletions packages/css/src/pagination/README.md
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.
75 changes: 75 additions & 0 deletions packages/css/src/pagination/pagination.scss
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;
}
}
107 changes: 107 additions & 0 deletions packages/react/src/Pagination/Pagination.test.tsx
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)
})
})
174 changes: 174 additions & 0 deletions packages/react/src/Pagination/Pagination.tsx
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'
Loading

0 comments on commit 0442f7b

Please sign in to comment.