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 | |
| parent | 98044be08600daf6bd7c7e1a4adada319dbcbbaf (diff) | |
refactor(components): rewrite Pagination component
Diffstat (limited to 'src')
18 files changed, 702 insertions, 518 deletions
diff --git a/src/components/atoms/buttons/button-link/button-link.tsx b/src/components/atoms/buttons/button-link/button-link.tsx index 96f5d3e..9ac3853 100644 --- a/src/components/atoms/buttons/button-link/button-link.tsx +++ b/src/components/atoms/buttons/button-link/button-link.tsx @@ -11,6 +11,14 @@ export type ButtonLinkProps = Omit< */ children: ReactNode; /** + * Should the link be disabled? + * + * Be aware, if disable the link will be replaced by a `span` element. + * + * @default false + */ + isDisabled?: boolean; + /** * True if it is an external link. * * @default false @@ -44,6 +52,7 @@ export const ButtonLink: FC<ButtonLinkProps> = ({ className = '', kind = 'secondary', shape = 'rectangle', + isDisabled = false, isExternal = false, rel = '', to, @@ -52,6 +61,14 @@ export const ButtonLink: FC<ButtonLinkProps> = ({ const kindClass = styles[`btn--${kind}`]; const shapeClass = styles[`btn--${shape}`]; const btnClass = `${styles.btn} ${kindClass} ${shapeClass} ${className}`; + + if (isDisabled) + return ( + <span {...props} className={btnClass} data-disabled> + {children} + </span> + ); + const linkRel = isExternal && !rel.includes('external') ? `external ${rel}` : rel; diff --git a/src/components/molecules/nav/index.ts b/src/components/molecules/nav/index.ts index ca84088..2f9b8e3 100644 --- a/src/components/molecules/nav/index.ts +++ b/src/components/molecules/nav/index.ts @@ -2,4 +2,3 @@ export * from './breadcrumb'; export * from './nav-item'; export * from './nav-link'; export * from './nav-list'; -export * from './pagination'; diff --git a/src/components/molecules/nav/pagination.module.scss b/src/components/molecules/nav/pagination.module.scss deleted file mode 100644 index 8b06a95..0000000 --- a/src/components/molecules/nav/pagination.module.scss +++ /dev/null @@ -1,43 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; - -.wrapper { - .list { - justify-content: center; - - &--pages { - margin-bottom: var(--spacing-sm); - } - } - - .link { - height: 100%; - min-width: 5ch; - min-height: 6ex; - position: relative; - - &:not(&--disabled) { - &:hover, - &:focus { - z-index: 3; - } - } - - &--number { - padding: 0; - } - - &--disabled { - display: flex; - place-content: center; - align-items: center; - background: var(--color-bg); - border: fun.convert-px(3) solid var(--color-primary-darker); - border-radius: fun.convert-px(5); - color: var(--color-primary-darker); - font-size: var(--font-size-md); - font-weight: 600; - text-decoration: underline transparent 0; - transform: scale(var(--scale-down, 0.94)); - } - } -} diff --git a/src/components/molecules/nav/pagination.stories.tsx b/src/components/molecules/nav/pagination.stories.tsx deleted file mode 100644 index 678c574..0000000 --- a/src/components/molecules/nav/pagination.stories.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Pagination } from './pagination'; - -/** - * Pagination - Storybook Meta - */ -export default { - title: 'Molecules/Navigation/Pagination', - component: Pagination, - args: { - baseUrl: '/page/', - siblings: 1, - }, - argTypes: { - 'aria-label': { - control: { - type: 'text', - }, - description: 'An accessible name for the pagination.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - baseUrl: { - control: { - type: 'text', - }, - description: 'The url prefix.', - table: { - category: 'Options', - defaultValue: { summary: '/page/' }, - }, - type: { - name: 'string', - required: false, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the pagination wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - current: { - control: { - type: 'number', - }, - description: 'The current page number.', - type: { - name: 'number', - required: true, - }, - }, - perPage: { - control: { - type: 'number', - }, - description: 'The number of items per page.', - 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} /> -); - -/** - * Pagination Stories - Less than 5 pages - */ -export const WithoutDots = Template.bind({}); -WithoutDots.args = { - current: 2, - perPage: 10, - siblings: 2, - total: 50, -}; - -/** - * Pagination Stories - Truncated to the right. - */ -export const RightDots = Template.bind({}); -RightDots.args = { - current: 2, - perPage: 10, - siblings: 2, - total: 80, -}; - -/** - * Pagination Stories - Truncated to the left. - */ -export const LeftDots = Template.bind({}); -LeftDots.args = { - current: 7, - perPage: 10, - siblings: 2, - total: 80, -}; - -/** - * Pagination Stories - Truncated both sides. - */ -export const LeftAndRightDots = Template.bind({}); -LeftAndRightDots.args = { - current: 6, - perPage: 10, - siblings: 2, - total: 150, -}; - -/** - * Pagination Stories - Without previous link - */ -export const WithoutPreviousLink = Template.bind({}); -WithoutPreviousLink.args = { - current: 1, - perPage: 10, - siblings: 2, - total: 50, -}; - -/** - * Pagination Stories - Without next link - */ -export const WithoutNextLink = Template.bind({}); -WithoutNextLink.args = { - current: 5, - perPage: 10, - siblings: 2, - total: 50, -}; diff --git a/src/components/molecules/nav/pagination.test.tsx b/src/components/molecules/nav/pagination.test.tsx deleted file mode 100644 index 7662d5f..0000000 --- a/src/components/molecules/nav/pagination.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Pagination } from './pagination'; - -const total = 50; -const perPage = 10; - -describe('Pagination', () => { - it('renders previous and next page links', () => { - render(<Pagination current={2} total={total} perPage={perPage} />); - expect( - screen.getByRole('link', { name: /Previous page/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: /Next page/i }) - ).toBeInTheDocument(); - }); - - it('renders the page links except for the current one', () => { - render( - <Pagination current={2} siblings={2} total={total} perPage={perPage} /> - ); - expect(screen.getAllByRole('link', { name: /Page / })).toHaveLength( - total / perPage - 1 - ); - }); -}); diff --git a/src/components/molecules/nav/pagination.tsx b/src/components/molecules/nav/pagination.tsx deleted file mode 100644 index 73517c3..0000000 --- a/src/components/molecules/nav/pagination.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/* eslint-disable max-statements */ -import { type FC, Fragment, type ReactNode } from 'react'; -import { useIntl } from 'react-intl'; -import { ButtonLink, List, ListItem } from '../../atoms'; -import styles from './pagination.module.scss'; - -export type PaginationProps = { - /** - * An accessible name for the pagination. - */ - 'aria-label'?: string; - /** - * The url part before page number. Default: /page/ - */ - baseUrl?: string; - /** - * Set additional classnames to the pagination wrapper. - */ - className?: string; - /** - * The current page number. - */ - current: number; - /** - * The number of items per page. - */ - perPage: number; - /** - * The number of siblings on one side of the current page. Default: 1. - */ - siblings?: number; - /** - * The total number of items. - */ - total: number; -}; - -/** - * Pagination component - * - * Render a page-based navigation. - */ -export const Pagination: FC<PaginationProps> = ({ - baseUrl = '/page/', - className = '', - current, - perPage, - siblings = 2, - total, - ...props -}) => { - const intl = useIntl(); - const totalPages = Math.round(total / perPage); - const hasPreviousPage = current > 1; - const previousPageName = intl.formatMessage( - { - defaultMessage: '{icon} Previous page', - description: 'Pagination: previous page link', - id: 'aMFqPH', - }, - { icon: '←' } - ); - const previousPageUrl = `${baseUrl}${current - 1}`; - const hasNextPage = current < totalPages; - const nextPageName = intl.formatMessage( - { - defaultMessage: 'Next page {icon}', - description: 'Pagination: Next page link', - id: 'R4yaW6', - }, - { icon: '→' } - ); - const nextPageUrl = `${baseUrl}${current + 1}`; - - /** - * Create an array with a range of values from start value to end value. - * - * @param {number} start - The first value. - * @param {number} end - The last value. - * @returns {number[]} An array from start value to end value. - */ - const range = (start: number, end: number): number[] => - Array.from({ length: end - start + 1 }, (_, index) => index + start); - - /** - * Get the pagination range. - * - * @param currentPage - The current page number. - * @param maxPages - The total pages number. - * @returns {(number|string)[]} An array of page numbers with or without dots. - */ - const getPaginationRange = ( - currentPage: number, - maxPages: number - ): (number | string)[] => { - const dots = '\u2026'; - - /** - * Show left dots if current page less left siblings is greater than the - * first two pages. - */ - const hasLeftDots = currentPage - siblings > 2; - - /** - * Show right dots if current page plus right siblings is lower than the - * total of pages less the last page. - */ - const hasRightDots = currentPage + siblings < maxPages - 1; - - if (hasLeftDots && hasRightDots) { - const middleItems = range(currentPage - siblings, currentPage + siblings); - return [1, dots, ...middleItems, dots, maxPages]; - } - - if (hasLeftDots) { - const rightItems = range(currentPage - siblings, maxPages); - return [1, dots, ...rightItems]; - } - - if (hasRightDots) { - const leftItems = range(1, currentPage + siblings); - return [...leftItems, dots, maxPages]; - } - - return range(1, maxPages); - }; - - /** - * Get a link or a span wrapped in a list item. - * - * @param {string} id - The item id. - * @param {ReactNode} body - The link body. - * @param {string} [link] - An URL. - * @returns {JSX.Element} The list item. - */ - const getItem = (id: string, body: ReactNode, link?: string): JSX.Element => { - const linkModifier = id.startsWith('page') ? 'link--number' : ''; - const kind = id === 'previous' || id === 'next' ? 'tertiary' : 'secondary'; - const linkClass = `${styles.link} ${styles[linkModifier]}`; - const disabledLinkClass = `${styles.link} ${styles['link--disabled']}`; - - return ( - <ListItem className={styles.item}> - {link ? ( - <ButtonLink className={linkClass} kind={kind} to={link}> - {body} - </ButtonLink> - ) : ( - <span className={disabledLinkClass}>{body}</span> - )} - </ListItem> - ); - }; - - /** - * Get the list of pages. - * - * @param {number} currentPage - The current page number. - * @param {number} maxPages - The total of pages. - * @returns {JSX.Element[]} The list items. - */ - const getPages = (currentPage: number, maxPages: number): JSX.Element[] => { - const pagesRange = getPaginationRange(currentPage, maxPages); - - return pagesRange.map((page, index) => { - const id = typeof page === 'string' ? `dots-${index}` : `page-${page}`; - const currentPagePrefix = intl.formatMessage({ - defaultMessage: 'You are here:', - description: 'Pagination: current page indication', - id: 'yE/Jdz', - }); - const body = - typeof page === 'string' - ? page // dots - : intl.formatMessage( - { - defaultMessage: '<a11y>Page </a11y>{number}', - description: 'Pagination: page number', - id: 'TSXPzr', - }, - { - number: page, - a11y: (chunks: ReactNode) => ( - // eslint-disable-next-line react/jsx-no-literals - <span className="screen-reader-text"> - {page === currentPage && currentPagePrefix} - {chunks} - </span> - ), - } - ); - const url = - page === currentPage || typeof page === 'string' - ? undefined - : `${baseUrl}${page}`; - - return <Fragment key={id}>{getItem(id, body, url)}</Fragment>; - }); - }; - const navClass = `${styles.wrapper} ${className}`; - const listClass = `${styles.list} ${styles['list--pages']}`; - - return ( - <nav {...props} className={navClass}> - <List className={listClass} hideMarker isInline spacing="2xs"> - {getPages(current, totalPages)} - </List> - <List className={styles.list} hideMarker isInline spacing="xs"> - {hasPreviousPage - ? getItem('previous', previousPageName, previousPageUrl) - : null} - {hasNextPage ? getItem('next', nextPageName, nextPageUrl) : null} - </List> - </nav> - ); -}; 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); diff --git a/src/i18n/en.json b/src/i18n/en.json index d0f951d..8581273 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -187,6 +187,10 @@ "defaultMessage": "Contact", "description": "ContactPage: page title" }, + "AmHSC4": { + "defaultMessage": "Go to page {number}", + "description": "PostsList: pagination page link label" + }, "B290Ph": { "defaultMessage": "Thanks, your comment was successfully sent.", "description": "PageLayout: comment form success message" @@ -255,6 +259,10 @@ "defaultMessage": "Contact form", "description": "ContactForm: form accessible name" }, + "HaKhih": { + "defaultMessage": "Go to next page", + "description": "PostsList: pagination forward link label" + }, "HohQPh": { "defaultMessage": "Thematics", "description": "Error404Page: thematics list widget title" @@ -343,6 +351,10 @@ "defaultMessage": "{minutesCount} minutes {secondsCount} seconds", "description": "useReadingTime: minutes + seconds count" }, + "PHO94k": { + "defaultMessage": "Go to previous page", + "description": "PostsList: pagination backward link label" + }, "PXp2hv": { "defaultMessage": "{websiteName} | Front-end developer: WordPress/React", "description": "HomePage: SEO - Page title" @@ -367,10 +379,6 @@ "defaultMessage": "Find me elsewhere", "description": "ContactPage: social media widget title" }, - "R4yaW6": { - "defaultMessage": "Next page {icon}", - "description": "Pagination: Next page link" - }, "R895yC": { "defaultMessage": "CV", "description": "Layout: main nav - cv link" @@ -403,10 +411,6 @@ "defaultMessage": "Subscribe", "description": "HomePage: RSS feed subscription text" }, - "TSXPzr": { - "defaultMessage": "<a11y>Page </a11y>{number}", - "description": "Pagination: page number" - }, "TpyFZ6": { "defaultMessage": "An error occurred:", "description": "Contact: error message" @@ -491,10 +495,6 @@ "defaultMessage": "Close menu", "description": "MainNav: Close label" }, - "aMFqPH": { - "defaultMessage": "{icon} Previous page", - "description": "Pagination: previous page link" - }, "azgQuH": { "defaultMessage": "You should read {title}", "description": "Sharing: subject text" @@ -579,6 +579,10 @@ "defaultMessage": "Linux", "description": "HomePage: link to Linux thematic" }, + "k1aA+G": { + "defaultMessage": "Pagination", + "description": "PostsList: pagination accessible name" + }, "kNBXyK": { "defaultMessage": "Total:", "description": "Page: total label" @@ -607,6 +611,10 @@ "defaultMessage": "Copied!", "description": "usePrism: copy button text (clicked)" }, + "nwDGkZ": { + "defaultMessage": "Current page, page {number}", + "description": "PostsList: pagination current page label" + }, "nwbzKm": { "defaultMessage": "Legal notice", "description": "Layout: Legal notice label" @@ -755,10 +763,6 @@ "defaultMessage": "CC BY SA", "description": "Layout: copyright title" }, - "yE/Jdz": { - "defaultMessage": "You are here:", - "description": "Pagination: current page indication" - }, "yIZ+AC": { "defaultMessage": "Topics:", "description": "Summary: topics label" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 318e7a0..5687cdc 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -187,6 +187,10 @@ "defaultMessage": "Contact", "description": "ContactPage: page title" }, + "AmHSC4": { + "defaultMessage": "Aller à la page {number}", + "description": "PostsList: pagination page link label" + }, "B290Ph": { "defaultMessage": "Merci, votre commentaire a été envoyé avec succès.", "description": "PageLayout: comment form success message" @@ -255,6 +259,10 @@ "defaultMessage": "Formulaire de contact", "description": "ContactForm: form accessible name" }, + "HaKhih": { + "defaultMessage": "Aller à la page suivante", + "description": "PostsList: pagination forward link label" + }, "HohQPh": { "defaultMessage": "Thématiques", "description": "Error404Page: thematics list widget title" @@ -343,6 +351,10 @@ "defaultMessage": "{minutesCount} minutes {secondsCount} secondes", "description": "useReadingTime: minutes + seconds count" }, + "PHO94k": { + "defaultMessage": "Aller à la page précédente", + "description": "PostsList: pagination backward link label" + }, "PXp2hv": { "defaultMessage": "{websiteName} | Intégrateur web - Développeur WordPress / React", "description": "HomePage: SEO - Page title" @@ -367,10 +379,6 @@ "defaultMessage": "Retrouvez-moi ailleurs", "description": "ContactPage: social media widget title" }, - "R4yaW6": { - "defaultMessage": "Page suivante {icon}", - "description": "Pagination: Next page link" - }, "R895yC": { "defaultMessage": "CV", "description": "Layout: main nav - cv link" @@ -403,10 +411,6 @@ "defaultMessage": "Vous abonner", "description": "HomePage: RSS feed subscription text" }, - "TSXPzr": { - "defaultMessage": "<a11y>Page </a11y>{number}", - "description": "Pagination: page number" - }, "TpyFZ6": { "defaultMessage": "Une erreur est survenue :", "description": "Contact: error message" @@ -491,10 +495,6 @@ "defaultMessage": "Fermer le menu", "description": "MainNav: Close label" }, - "aMFqPH": { - "defaultMessage": "{icon} Page précédente", - "description": "Pagination: previous page link" - }, "azgQuH": { "defaultMessage": "Vous devriez lire {title}", "description": "Sharing: subject text" @@ -579,6 +579,10 @@ "defaultMessage": "Linux", "description": "HomePage: link to Linux thematic" }, + "k1aA+G": { + "defaultMessage": "Pagination", + "description": "PostsList: pagination accessible name" + }, "kNBXyK": { "defaultMessage": "Total :", "description": "Page: total label" @@ -607,6 +611,10 @@ "defaultMessage": "Copié !", "description": "usePrism: copy button text (clicked)" }, + "nwDGkZ": { + "defaultMessage": "Page actuelle, page {number}", + "description": "PostsList: pagination current page label" + }, "nwbzKm": { "defaultMessage": "Mentions légales", "description": "Layout: Legal notice label" @@ -755,10 +763,6 @@ "defaultMessage": "CC BY SA", "description": "Layout: copyright title" }, - "yE/Jdz": { - "defaultMessage": "Vous êtes ici :", - "description": "Pagination: current page indication" - }, "yIZ+AC": { "defaultMessage": "Sujets :", "description": "Summary: topics label" diff --git a/src/styles/abstracts/placeholders/_buttons.scss b/src/styles/abstracts/placeholders/_buttons.scss index 896c5a9..7fc8a6b 100644 --- a/src/styles/abstracts/placeholders/_buttons.scss +++ b/src/styles/abstracts/placeholders/_buttons.scss @@ -32,11 +32,12 @@ color: var(--color-fg-inverted); text-shadow: fun.convert-px(2) fun.convert-px(2) 0 var(--color-shadow); - &:disabled { + &:disabled, + &[data-disabled="true"] { background: var(--color-primary-darker); } - &:not(:disabled) { + &:not(:disabled, [data-disabled="true"]) { &:hover, &:focus { background: var(--color-primary-light); @@ -56,7 +57,8 @@ } &:active, - &[aria-pressed="true"] { + &[aria-pressed="true"], + &[aria-current] { box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary), 0 0 0 fun.convert-px(3) var(--color-primary-darker), @@ -74,20 +76,22 @@ %secondary-button { background: var(--color-bg); border: fun.convert-px(3) solid var(--color-primary); - box-shadow: - fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) var(--color-shadow), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) - var(--color-shadow), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) - var(--color-shadow); color: var(--color-primary); - &:disabled { + &:disabled, + &[data-disabled="true"] { border-color: var(--color-border-dark); color: var(--color-fg-light); } - &:not(:disabled) { + &:not(:disabled, [data-disabled="true"]) { + box-shadow: + fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) var(--color-shadow), + fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) + var(--color-shadow), + fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) + var(--color-shadow); + &:hover, &:focus { border-color: var(--color-primary-light); @@ -109,7 +113,8 @@ } &:active, - &[aria-pressed="true"] { + &[aria-pressed="true"], + &[aria-current] { box-shadow: 0 0 0 0 var(--color-shadow); transform: scale(var(--scale-down, 0.94)); @@ -124,14 +129,10 @@ %tertiary-button { background: var(--color-bg); border: fun.convert-px(3) solid var(--color-primary); - box-shadow: - fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg), - fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-dark), - fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg), - fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-dark); color: var(--color-primary); - &:disabled { + &:disabled, + &:where([data-disabled="true"]) { color: var(--color-fg-light); border-color: var(--color-border-dark); box-shadow: @@ -141,7 +142,13 @@ fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-darker); } - &:not(:disabled) { + &:not(:disabled, [data-disabled="true"]) { + box-shadow: + fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg), + fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-dark), + fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg), + fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-dark); + &:hover, &:focus { border-color: var(--color-primary-light); @@ -170,6 +177,15 @@ translateY(#{fun.convert-px(6)}); } } + + &:not(:disabled, [data-disabled="true"]):where( + &:active, + &[aria-pressed="true"] + ), + &[aria-current] { + box-shadow: 0 0 0 0 var(--color-shadow); + transform: translateX(#{fun.convert-px(5)}) translateY(#{fun.convert-px(6)}); + } } %circle-or-square-button { |
