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