summaryrefslogtreecommitdiffstats
path: root/src/components/molecules/nav
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/nav')
-rw-r--r--src/components/molecules/nav/pagination.module.scss51
-rw-r--r--src/components/molecules/nav/pagination.stories.tsx175
-rw-r--r--src/components/molecules/nav/pagination.test.tsx26
-rw-r--r--src/components/molecules/nav/pagination.tsx220
4 files changed, 472 insertions, 0 deletions
diff --git a/src/components/molecules/nav/pagination.module.scss b/src/components/molecules/nav/pagination.module.scss
new file mode 100644
index 0000000..a8cef47
--- /dev/null
+++ b/src/components/molecules/nav/pagination.module.scss
@@ -0,0 +1,51 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ .list {
+ @extend %flex-list;
+
+ align-items: stretch;
+ justify-content: center;
+ position: relative;
+ row-gap: var(--spacing-xs);
+ column-gap: var(--spacing-sm);
+
+ &--pages {
+ column-gap: var(--spacing-2xs);
+ margin-top: 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
new file mode 100644
index 0000000..b31c2b5
--- /dev/null
+++ b/src/components/molecules/nav/pagination.stories.tsx
@@ -0,0 +1,175 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import PaginationComponent from './pagination';
+
+/**
+ * Pagination - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Pagination',
+ component: PaginationComponent,
+ 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,
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+ <IntlProvider locale="en">
+ <Story />
+ </IntlProvider>
+ ),
+ ],
+} as ComponentMeta<typeof PaginationComponent>;
+
+const Template: ComponentStory<typeof PaginationComponent> = (args) => (
+ <PaginationComponent {...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
new file mode 100644
index 0000000..2c4a063
--- /dev/null
+++ b/src/components/molecules/nav/pagination.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@test-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
new file mode 100644
index 0000000..38f6841
--- /dev/null
+++ b/src/components/molecules/nav/pagination.tsx
@@ -0,0 +1,220 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import { FC, Fragment, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+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.
+ */
+const Pagination: FC<PaginationProps> = ({
+ baseUrl = '/page/',
+ className = '',
+ current,
+ perPage,
+ siblings = 1,
+ total,
+ ...props
+}) => {
+ const intl = useIntl();
+ const totalPages = Math.ceil(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[] => {
+ const length = end - start + 1;
+
+ return Array.from({ length }, (_, 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';
+
+ return (
+ <li className={styles.item}>
+ {link ? (
+ <ButtonLink
+ kind={kind}
+ target={link}
+ className={`${styles.link} ${styles[linkModifier]}`}
+ >
+ {body}
+ </ButtonLink>
+ ) : (
+ <span className={`${styles.link} ${styles['link--disabled']}`}>
+ {body}
+ </span>
+ )}
+ </li>
+ );
+ };
+
+ /**
+ * 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'
+ ? '\u2026'
+ : intl.formatMessage(
+ {
+ defaultMessage: '<a11y>Page </a11y>{number}',
+ description: 'Pagination: page number',
+ id: 'TSXPzr',
+ },
+ {
+ number: page,
+ a11y: (chunks: ReactNode) => (
+ <span className="screen-reader-text">
+ {page === currentPage && currentPagePrefix}
+ {chunks}
+ </span>
+ ),
+ }
+ );
+ const url =
+ page === currentPage || typeof page === 'string'
+ ? undefined
+ : `${baseUrl}${page}`;
+
+ return <Fragment key={`item-${id}`}>{getItem(id, body, url)}</Fragment>;
+ });
+ };
+
+ return (
+ <nav className={`${styles.wrapper} ${className}`} {...props}>
+ <ul className={styles.list}>
+ {hasPreviousPage &&
+ getItem('previous', previousPageName, previousPageUrl)}
+ {hasNextPage && getItem('next', nextPageName, nextPageUrl)}
+ </ul>
+ <ul className={`${styles.list} ${styles['list--pages']}`}>
+ {getPages(current, totalPages)}
+ </ul>
+ </nav>
+ );
+};
+
+export default Pagination;