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 | |
| parent | 98044be08600daf6bd7c7e1a4adada319dbcbbaf (diff) | |
refactor(components): rewrite Pagination component
Diffstat (limited to 'src/components/organisms')
| -rw-r--r-- | src/components/organisms/index.ts | 1 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.module.scss | 6 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.tsx | 86 | ||||
| -rw-r--r-- | src/components/organisms/nav/index.ts | 1 | ||||
| -rw-r--r-- | src/components/organisms/nav/pagination/index.ts | 1 | ||||
| -rw-r--r-- | src/components/organisms/nav/pagination/pagination.module.scss | 15 | ||||
| -rw-r--r-- | src/components/organisms/nav/pagination/pagination.stories.tsx | 150 | ||||
| -rw-r--r-- | src/components/organisms/nav/pagination/pagination.test.tsx | 176 | ||||
| -rw-r--r-- | src/components/organisms/nav/pagination/pagination.tsx | 183 |
9 files changed, 610 insertions, 9 deletions
diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 386eebf..5e659b5 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -1,5 +1,6 @@ export * from './forms'; export * from './layout'; export * from './modals'; +export * from './nav'; export * from './toolbar'; export * from './widgets'; diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss index 759902a..cc5acda 100644 --- a/src/components/organisms/layout/posts-list.module.scss +++ b/src/components/organisms/layout/posts-list.module.scss @@ -24,6 +24,7 @@ } .year { + margin-bottom: var(--spacing-md); padding-bottom: fun.convert-px(3); background: linear-gradient( to top, @@ -58,3 +59,8 @@ .progress { margin-block: var(--spacing-md); } + +.pagination { + margin-inline: auto; + margin-block-end: var(--spacing-lg); +} diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx index cde81e6..30beb50 100644 --- a/src/components/organisms/layout/posts-list.tsx +++ b/src/components/organisms/layout/posts-list.tsx @@ -11,7 +11,12 @@ import { List, ListItem, } from '../../atoms'; -import { Pagination, type PaginationProps } from '../../molecules'; +import { + Pagination, + type PaginationProps, + type RenderPaginationItemAriaLabel, + type RenderPaginationLink, +} from '../nav'; import { NoResults, type NoResultsProps } from './no-results'; import styles from './posts-list.module.scss'; import { Summary, type SummaryProps } from './summary'; @@ -25,9 +30,13 @@ export type Post = Omit<SummaryProps, 'titleLevel'> & { export type YearCollection = Record<string, Post[]>; -export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> & +export type PostsListProps = Pick<PaginationProps, 'siblings'> & Pick<NoResultsProps, 'searchPage'> & { /** + * The pagination base url. + */ + baseUrl?: string; + /** * True to display the posts by year. Default: false. */ byYear?: boolean; @@ -86,7 +95,7 @@ const sortPostsByYear = (data: Post[]): YearCollection => { * Render a list of post summaries. */ export const PostsList: FC<PostsListProps> = ({ - baseUrl, + baseUrl = '', byYear = false, isLoading = false, loadMore, @@ -164,6 +173,10 @@ export const PostsList: FC<PostsListProps> = ({ )); }; + const loadedPostsCount = + pageNumber === 1 + ? posts.length + : pageNumber * blog.postsPerPage + posts.length; const progressInfo = intl.formatMessage( { defaultMessage: @@ -171,7 +184,10 @@ export const PostsList: FC<PostsListProps> = ({ description: 'PostsList: loaded articles progress', id: '9MeLN3', }, - { articlesCount: posts.length, total } + { + articlesCount: loadedPostsCount, + total, + } ); const loadMoreBody = intl.formatMessage({ @@ -202,7 +218,7 @@ export const PostsList: FC<PostsListProps> = ({ <ProgressBar aria-label={progressInfo} className={styles.progress} - current={posts.length} + current={loadedPostsCount} id={progressBarId} isCentered isLoading={isLoading} @@ -223,16 +239,68 @@ export const PostsList: FC<PostsListProps> = ({ </> ); + const paginationAriaLabel = intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'PostsList: pagination accessible name', + id: 'k1aA+G', + }); + + const renderItemAriaLabel: RenderPaginationItemAriaLabel = useCallback( + ({ kind, pageNumber: page, isCurrentPage }) => { + switch (kind) { + case 'backward': + return intl.formatMessage({ + defaultMessage: 'Go to previous page', + description: 'PostsList: pagination backward link label', + id: 'PHO94k', + }); + case 'forward': + return intl.formatMessage({ + defaultMessage: 'Go to next page', + description: 'PostsList: pagination forward link label', + id: 'HaKhih', + }); + case 'number': + default: + return isCurrentPage + ? intl.formatMessage( + { + defaultMessage: 'Current page, page {number}', + description: 'PostsList: pagination current page label', + id: 'nwDGkZ', + }, + { number: page } + ) + : intl.formatMessage( + { + defaultMessage: 'Go to page {number}', + description: 'PostsList: pagination page link label', + id: 'AmHSC4', + }, + { number: page } + ); + } + }, + [intl] + ); + + const renderLink: RenderPaginationLink = useCallback( + (page) => `${baseUrl}${page}`, + [baseUrl] + ); + const getPagination = () => { - if (posts.length < blog.postsPerPage) return null; + if (total < blog.postsPerPage) return null; return ( <Pagination - baseUrl={baseUrl} + aria-label={paginationAriaLabel} + className={styles.pagination} current={pageNumber} - perPage={blog.postsPerPage} + renderItemAriaLabel={renderItemAriaLabel} + renderLink={renderLink} siblings={siblings} - total={total} + total={Math.round(total / blog.postsPerPage)} /> ); }; 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); |
