diff --git a/packages/css/src/index.scss b/packages/css/src/index.scss index 8ac71c86a4..c1cdf7b5ae 100644 --- a/packages/css/src/index.scss +++ b/packages/css/src/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./pagination/pagination"; @import "./accordion/accordion"; @import "./alert/alert"; @import "./aspect-ratio/aspect-ratio"; diff --git a/packages/css/src/link/link.scss b/packages/css/src/link/link.scss index 8668ee894c..3634ceab37 100644 --- a/packages/css/src/link/link.scss +++ b/packages/css/src/link/link.scss @@ -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; } } diff --git a/packages/css/src/pagination/README.md b/packages/css/src/pagination/README.md new file mode 100644 index 0000000000..8f731d8497 --- /dev/null +++ b/packages/css/src/pagination/README.md @@ -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. diff --git a/packages/css/src/pagination/pagination.scss b/packages/css/src/pagination/pagination.scss new file mode 100644 index 0000000000..0ee17e2c3e --- /dev/null +++ b/packages/css/src/pagination/pagination.scss @@ -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; + } +} diff --git a/packages/react/src/Pagination/Pagination.test.tsx b/packages/react/src/Pagination/Pagination.test.tsx new file mode 100644 index 0000000000..6c27b68e0f --- /dev/null +++ b/packages/react/src/Pagination/Pagination.test.tsx @@ -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() + const component = container.querySelector(':only-child') + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + const component = container.querySelector(':only-child') + expect(component).toHaveClass('amsterdam-pagination') + }) + + it('can have a additional class name', () => { + const { container } = render() + 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() + 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() + 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() + 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() + + 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() + + 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 + } + + render() + + 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() + const { container } = render() + const component = container.querySelector(':only-child') + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/Pagination/Pagination.tsx b/packages/react/src/Pagination/Pagination.tsx new file mode 100644 index 0000000000..6ee4313b9c --- /dev/null +++ b/packages/react/src/Pagination/Pagination.tsx @@ -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 { + /** + * 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 { + // 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>( + (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, + ) => { + 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 ( + + ) + }, +) + +Pagination.displayName = 'Pagination' diff --git a/packages/react/src/Pagination/README.md b/packages/react/src/Pagination/README.md new file mode 100644 index 0000000000..24159b2865 --- /dev/null +++ b/packages/react/src/Pagination/README.md @@ -0,0 +1,3 @@ +# React Pagination component + +[Pagination documentation](../../../css/src/pagination/README.md) diff --git a/packages/react/src/Pagination/index.ts b/packages/react/src/Pagination/index.ts new file mode 100644 index 0000000000..f1805927d1 --- /dev/null +++ b/packages/react/src/Pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './Pagination' +export type { PaginationProps } from './Pagination' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 73f0349eda..87b49ab25c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './Pagination' export * from './Screen' export * from './Switch' export * from './Highlight' diff --git a/proprietary/tokens/src/components/amsterdam/pagination.tokens.json b/proprietary/tokens/src/components/amsterdam/pagination.tokens.json new file mode 100644 index 0000000000..800f245ec7 --- /dev/null +++ b/proprietary/tokens/src/components/amsterdam/pagination.tokens.json @@ -0,0 +1,31 @@ +{ + "amsterdam": { + "pagination": { + "color": { "value": "{amsterdam.color.primary-blue}" }, + "font-family": { + "value": "{amsterdam.typography.font-family}" + }, + "font-weight": { + "value": "{amsterdam.typography.font-weight.normal}" + }, + "line-height": { "value": "{amsterdam.typography.text-level.6.line-height}" }, + "narrow": { + "font-size": { "value": "{amsterdam.typography.text-level.6.narrow.font-size}" } + }, + "wide": { + "font-size": { "value": "{amsterdam.typography.text-level.6.wide.font-size}" } + }, + "button": { + "current": { + "font-weight": { + "value": "{amsterdam.typography.font-weight.bold}" + } + }, + "hover": { + "color": { "value": "{amsterdam.color.dark-blue}" } + }, + "outline-offset": { "value": "{amsterdam.focus.outline-offset}" } + } + } + } +} diff --git a/storybook/storybook-react/src/Pagination/Pagination.docs.mdx b/storybook/storybook-react/src/Pagination/Pagination.docs.mdx new file mode 100644 index 0000000000..7baea8c290 --- /dev/null +++ b/storybook/storybook-react/src/Pagination/Pagination.docs.mdx @@ -0,0 +1,11 @@ +import { Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as PaginationStories from "./Pagination.stories.tsx"; +import README from "../../../../packages/css/src/pagination/README.md?raw"; + + + +{README} + + + + diff --git a/storybook/storybook-react/src/Pagination/Pagination.stories.tsx b/storybook/storybook-react/src/Pagination/Pagination.stories.tsx new file mode 100644 index 0000000000..50f30162b9 --- /dev/null +++ b/storybook/storybook-react/src/Pagination/Pagination.stories.tsx @@ -0,0 +1,24 @@ +/** + * @license EUPL-1.2+ + * Copyright (c) 2023 Gemeente Amsterdam + */ + +import { Pagination } from '@amsterdam/design-system-react' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Navigation/Pagination', + component: Pagination, + args: { + page: 1, + maxVisiblePages: 7, + totalPages: 10, + }, + argTypes: { onPageChange: { action: 'page changed' } }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {}