aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms
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/components/organisms
parent98044be08600daf6bd7c7e1a4adada319dbcbbaf (diff)
refactor(components): rewrite Pagination component
Diffstat (limited to 'src/components/organisms')
-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
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);