Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pagination component #674

Merged
merged 20 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved

## 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);
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
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;
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved

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;
}
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
}

.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
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
* 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>>(
hcvdhaar marked this conversation as resolved.
Show resolved Hide resolved
(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
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
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