aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-10-24 18:48:57 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:15:24 +0100
commit3f8ae3f558446aba3870e90c899db25ad9321499 (patch)
tree30824d02705337309d9223f8c5a6bd8fc41d482c /src
parent98044be08600daf6bd7c7e1a4adada319dbcbbaf (diff)
refactor(components): rewrite Pagination component
Diffstat (limited to 'src')
-rw-r--r--src/components/atoms/buttons/button-link/button-link.tsx17
-rw-r--r--src/components/molecules/nav/index.ts1
-rw-r--r--src/components/molecules/nav/pagination.module.scss43
-rw-r--r--src/components/molecules/nav/pagination.stories.tsx171
-rw-r--r--src/components/molecules/nav/pagination.test.tsx27
-rw-r--r--src/components/molecules/nav/pagination.tsx216
-rw-r--r--src/components/organisms/index.ts1
-rw-r--r--src/components/organisms/layout/posts-list.module.scss6
-rw-r--r--src/components/organisms/layout/posts-list.tsx86
-rw-r--r--src/components/organisms/nav/index.ts1
-rw-r--r--src/components/organisms/nav/pagination/index.ts1
-rw-r--r--src/components/organisms/nav/pagination/pagination.module.scss15
-rw-r--r--src/components/organisms/nav/pagination/pagination.stories.tsx150
-rw-r--r--src/components/organisms/nav/pagination/pagination.test.tsx176
-rw-r--r--src/components/organisms/nav/pagination/pagination.tsx183
-rw-r--r--src/i18n/en.json36
-rw-r--r--src/i18n/fr.json36
-rw-r--r--src/styles/abstracts/placeholders/_buttons.scss54
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 {