diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-10-24 18:48:57 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:24 +0100 |
| commit | 3f8ae3f558446aba3870e90c899db25ad9321499 (patch) | |
| tree | 30824d02705337309d9223f8c5a6bd8fc41d482c /src/components/organisms/nav | |
| parent | 98044be08600daf6bd7c7e1a4adada319dbcbbaf (diff) | |
refactor(components): rewrite Pagination component
Diffstat (limited to 'src/components/organisms/nav')
6 files changed, 526 insertions, 0 deletions
diff --git a/src/components/organisms/nav/index.ts b/src/components/organisms/nav/index.ts new file mode 100644 index 0000000..cb72765 --- /dev/null +++ b/src/components/organisms/nav/index.ts @@ -0,0 +1 @@ +export * from './pagination'; diff --git a/src/components/organisms/nav/pagination/index.ts b/src/components/organisms/nav/pagination/index.ts new file mode 100644 index 0000000..cb72765 --- /dev/null +++ b/src/components/organisms/nav/pagination/index.ts @@ -0,0 +1 @@ +export * from './pagination'; diff --git a/src/components/organisms/nav/pagination/pagination.module.scss b/src/components/organisms/nav/pagination/pagination.module.scss new file mode 100644 index 0000000..13970d3 --- /dev/null +++ b/src/components/organisms/nav/pagination/pagination.module.scss @@ -0,0 +1,15 @@ +.wrapper { + display: flex; + flex-flow: column wrap; + gap: var(--spacing-sm); + align-items: center; + width: fit-content; +} + +.list { + place-content: center; +} + +.item { + display: flex; +} diff --git a/src/components/organisms/nav/pagination/pagination.stories.tsx b/src/components/organisms/nav/pagination/pagination.stories.tsx new file mode 100644 index 0000000..83f3a63 --- /dev/null +++ b/src/components/organisms/nav/pagination/pagination.stories.tsx @@ -0,0 +1,150 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { + Pagination, + type RenderPaginationItemAriaLabel, + type RenderPaginationLink, +} from './pagination'; + +/** + * Pagination - Storybook Meta + */ +export default { + title: 'Organisms/Nav/Pagination', + component: Pagination, + args: { + siblings: 1, + }, + argTypes: { + current: { + control: { + type: 'number', + }, + description: 'The current page number.', + type: { + name: 'number', + required: true, + }, + }, + siblings: { + control: { + type: 'number', + }, + description: + 'The number of pages to show next to the current page for one side.', + table: { + category: 'Options', + defaultValue: { summary: 1 }, + }, + type: { + name: 'number', + required: false, + }, + }, + total: { + control: { + type: 'number', + }, + description: 'The total number of items.', + type: { + name: 'number', + required: true, + }, + }, + }, +} as ComponentMeta<typeof Pagination>; + +const Template: ComponentStory<typeof Pagination> = (args) => ( + <Pagination {...args} /> +); + +const renderLink: RenderPaginationLink = (num: number) => `#page-${num}`; + +const renderItemAriaLabel: RenderPaginationItemAriaLabel = ({ + kind, + pageNumber, + isCurrentPage, +}) => { + switch (kind) { + case 'backward': + return 'Go to previous page'; + case 'forward': + return 'Go to next page'; + case 'number': + default: + return isCurrentPage + ? `Current page, page ${pageNumber}` + : `Go to page ${pageNumber}`; + } +}; + +/** + * Pagination Stories - More than 5 pages and current page is near the beginning + */ +export const RightEllipsis = Template.bind({}); +RightEllipsis.args = { + current: 2, + siblings: 2, + renderItemAriaLabel, + renderLink, + total: 50, +}; + +/** + * Pagination Stories - More than 5 pages and current page is near the end + */ +export const LeftEllipsis = Template.bind({}); +LeftEllipsis.args = { + current: 49, + siblings: 2, + renderItemAriaLabel, + renderLink, + total: 50, +}; + +/** + * Pagination Stories - More than 5 pages and current page is near the middle + */ +export const BothEllipsis = Template.bind({}); +BothEllipsis.args = { + current: 25, + siblings: 2, + renderItemAriaLabel, + renderLink, + total: 50, +}; + +/** + * Pagination Stories - Less than 5 pages + */ +export const WithoutEllipsis = Template.bind({}); +WithoutEllipsis.args = { + current: 2, + siblings: 2, + renderItemAriaLabel, + renderLink, + total: 5, +}; + +/** + * Pagination Stories - First page selected + */ +export const WithoutBackwardLink = Template.bind({}); +WithoutBackwardLink.args = { + current: 1, + siblings: 2, + renderItemAriaLabel, + renderLink, + total: 5, +}; + +/** + * Pagination Stories - Last page selected + */ +export const WithoutForwardLink = Template.bind({}); +WithoutForwardLink.args = { + current: 5, + siblings: 2, + renderItemAriaLabel, + renderLink, + total: 5, +}; diff --git a/src/components/organisms/nav/pagination/pagination.test.tsx b/src/components/organisms/nav/pagination/pagination.test.tsx new file mode 100644 index 0000000..336e306 --- /dev/null +++ b/src/components/organisms/nav/pagination/pagination.test.tsx @@ -0,0 +1,176 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { + Pagination, + type RenderPaginationItemAriaLabel, + type RenderPaginationLink, +} from './pagination'; + +const pagePrefix = '#page-'; +const backwardLabel = 'omnis assumenda ex'; +const forwardLabel = 'voluptatum aut molestiae'; +const currentPageLabelPrefix = 'est nostrum a'; +const pageLabelPrefix = 'reprehenderit qui unde'; + +const renderLink: RenderPaginationLink = (num: number) => `${pagePrefix}${num}`; + +const renderItemAriaLabel: RenderPaginationItemAriaLabel = ({ + kind, + pageNumber, + isCurrentPage, +}) => { + switch (kind) { + case 'backward': + return backwardLabel; + case 'forward': + return forwardLabel; + case 'number': + default: + return isCurrentPage + ? `${currentPageLabelPrefix}${pageNumber}` + : `${pageLabelPrefix}${pageNumber}`; + } +}; + +describe('Pagination', () => { + it('renders a list of items in a nav element', () => { + const ariaLabel = 'expedita repellat rem'; + const current = 1; + const total = 1; + + render( + <Pagination + aria-label={ariaLabel} + current={current} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} + total={total} + /> + ); + + expect( + rtlScreen.getByRole('navigation', { name: ariaLabel }) + ).toBeInTheDocument(); + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(total); + }); + + it('can render a forward link when there is more than one page', () => { + const ariaLabel = 'expedita repellat rem'; + const current = 1; + const total = 3; + + render( + <Pagination + aria-label={ariaLabel} + current={current} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} + total={total} + /> + ); + + expect( + rtlScreen.getByRole('link', { name: forwardLabel }) + ).toBeInTheDocument(); + // the pages links + the forward link + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(total + 1); + }); + + it('can render a backward link when the last page is selected', () => { + const ariaLabel = 'expedita repellat rem'; + const total = 3; + + render( + <Pagination + aria-label={ariaLabel} + current={total} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} + total={total} + /> + ); + + expect( + rtlScreen.getByRole('link', { name: backwardLabel }) + ).toBeInTheDocument(); + // the pages links + the backward link + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(total + 1); + }); + + it('can skip next pages when the total is high and first page is selected', () => { + const ariaLabel = 'expedita repellat rem'; + const current = 1; + const total = 50; + /* + * First page & the two next pages + 1 ellipsis + last page + forward link + */ + const expectedItemsCount = 6; + + render( + <Pagination + aria-label={ariaLabel} + current={current} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} + total={total} + /> + ); + + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(expectedItemsCount); + // The current page and the ellipsis should not be linked. + expect(rtlScreen.getAllByRole('link')).toHaveLength(expectedItemsCount - 2); + }); + + it('can skip previous pages when the total is high and last page is selected', () => { + const ariaLabel = 'expedita repellat rem'; + const current = 50; + const total = 50; + /* + * Last page & the two previous pages + 1 ellipsis + first page + backward + * link + */ + const expectedItemsCount = 6; + + render( + <Pagination + aria-label={ariaLabel} + current={current} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} + total={total} + /> + ); + + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(expectedItemsCount); + // The current page and the ellipsis should not be linked. + expect(rtlScreen.getAllByRole('link')).toHaveLength(expectedItemsCount - 2); + }); + + it('can render a custom number of siblings', () => { + const ariaLabel = 'expedita repellat rem'; + const siblings = 3; + const current = 10; + const total = 20; + /* + * Current page + 3 siblings on each side + first page + last page + 2 + * ellipsis + backward and forward links + */ + const expectedItemsCount = 13; + + render( + <Pagination + aria-label={ariaLabel} + current={current} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} + siblings={siblings} + total={total} + /> + ); + + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(expectedItemsCount); + /* eslint-disable-next-line @typescript-eslint/no-magic-numbers -- The + current page and the two ellipsis should not be linked. */ + expect(rtlScreen.getAllByRole('link')).toHaveLength(expectedItemsCount - 3); + }); +}); diff --git a/src/components/organisms/nav/pagination/pagination.tsx b/src/components/organisms/nav/pagination/pagination.tsx new file mode 100644 index 0000000..8e95122 --- /dev/null +++ b/src/components/organisms/nav/pagination/pagination.tsx @@ -0,0 +1,183 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { ButtonLink, Icon, Nav, type NavProps } from '../../../atoms'; +import { NavItem, NavList } from '../../../molecules'; +import styles from './pagination.module.scss'; + +export type PaginationItemKind = 'backward' | 'forward' | 'number'; + +type RenderPaginationItemAriaLabelProps = { + /** + * Does the item represent the current page? + */ + isCurrentPage?: boolean; + /** + * The item kind. + */ + kind: PaginationItemKind; + /** + * The linked page number. + */ + pageNumber: number; +}; + +export type RenderPaginationItemAriaLabel = ( + props: RenderPaginationItemAriaLabelProps +) => string; + +export type RenderPaginationLink = (page: number) => string; + +export type PaginationProps = Omit<NavProps, 'children'> & { + /** + * The currently active page number. + */ + current: number; + /** + * Function used to provide an accessible label to pagination items. + */ + renderItemAriaLabel: RenderPaginationItemAriaLabel; + /** + * Function used to create the href provided for each page link. + */ + renderLink: RenderPaginationLink; + /** + * The number of pages to show on each side of the current page. + * + * @default 1 + */ + siblings?: number; + /** + * The total number of pages. + */ + total: number; +}; + +type GetPagesProps = Pick<PaginationProps, 'current' | 'total'> & { + displayRange: number; +}; + +const getPages = ({ current, displayRange, total }: GetPagesProps) => + Array.from({ length: total }, (_, index) => { + const page = index + 1; + const isFirstPage = page === 1; + const isLastPage = page === total; + const isOutOfRangeFromStart = page < current - displayRange && !isFirstPage; + const isOutOfRangeFromEnd = page > current + displayRange && !isLastPage; + const isOutOfRange = isOutOfRangeFromStart || isOutOfRangeFromEnd; + const ellipsisId = isOutOfRangeFromStart + ? 'start-ellipsis' + : 'end-ellipsis'; + + return { + id: isOutOfRange ? ellipsisId : `page-${page}`, + number: isOutOfRangeFromStart || isOutOfRangeFromEnd ? null : page, + }; + }).filter( + (page, index, allPages) => + index === 0 || page.number !== allPages[index - 1]?.number + ); + +const PaginationWithRef: ForwardRefRenderFunction< + HTMLElement, + PaginationProps +> = ( + { + className = '', + current, + renderItemAriaLabel, + renderLink, + siblings = 1, + total, + ...props + }, + ref +) => { + const paginationClass = `${styles.wrapper} ${className}`; + const displayRange = + current === 1 || current === total ? siblings + 1 : siblings; + const hasPreviousPage = current > 1; + const hasNextPage = current < total; + const pages = getPages({ current, displayRange, total }); + const ellipsis = '\u2026' as const; + + return ( + <Nav {...props} className={paginationClass} ref={ref}> + <NavList + className={styles.list} + isInline + // eslint-disable-next-line react/jsx-no-literals + spacing="xs" + > + {hasPreviousPage ? ( + <NavItem className={styles.item}> + <ButtonLink + aria-label={renderItemAriaLabel({ + kind: 'backward', + pageNumber: current - 1, + })} + // eslint-disable-next-line react/jsx-no-literals + kind="secondary" + to={renderLink(current - 1)} + > + <Icon + aria-hidden + // eslint-disable-next-line react/jsx-no-literals + shape="arrow" + // eslint-disable-next-line react/jsx-no-literals + orientation="left" + /> + </ButtonLink> + </NavItem> + ) : null} + {pages.map((page) => { + const isCurrentPage = page.number === current; + + return ( + <NavItem className={styles.item} key={page.id}> + <ButtonLink + aria-current={isCurrentPage ? 'page' : undefined} + aria-label={ + page.number + ? renderItemAriaLabel({ + isCurrentPage, + kind: 'number', + pageNumber: page.number, + }) + : undefined + } + isDisabled={page.number === null || isCurrentPage} + // eslint-disable-next-line react/jsx-no-literals + kind="secondary" + to={page.number ? renderLink(page.number) : ''} + > + {page.number ?? ellipsis} + </ButtonLink> + </NavItem> + ); + })} + {hasNextPage ? ( + <NavItem className={styles.item}> + <ButtonLink + aria-label={renderItemAriaLabel({ + kind: 'forward', + pageNumber: current + 1, + })} + // eslint-disable-next-line react/jsx-no-literals + kind="secondary" + to={renderLink(current + 1)} + > + <Icon + aria-hidden + // eslint-disable-next-line react/jsx-no-literals + shape="arrow" + // eslint-disable-next-line react/jsx-no-literals + orientation="right" + /> + </ButtonLink> + </NavItem> + ) : null} + </NavList> + </Nav> + ); +}; + +export const Pagination = forwardRef(PaginationWithRef); |
