aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/nav
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-24 19:35:12 +0200
committerGitHub <noreply@github.com>2022-05-24 19:35:12 +0200
commitc85ab5ad43ccf52881ee224672c41ec30021cf48 (patch)
tree8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/molecules/nav
parent52404177c07a2aab7fc894362fb3060dff2431a0 (diff)
parent11b9de44a4b2f305a6a484187805e429b2767118 (diff)
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/molecules/nav')
-rw-r--r--src/components/molecules/nav/breadcrumb.module.scss19
-rw-r--r--src/components/molecules/nav/breadcrumb.stories.tsx81
-rw-r--r--src/components/molecules/nav/breadcrumb.test.tsx15
-rw-r--r--src/components/molecules/nav/breadcrumb.tsx127
-rw-r--r--src/components/molecules/nav/nav.module.scss22
-rw-r--r--src/components/molecules/nav/nav.stories.tsx107
-rw-r--r--src/components/molecules/nav/nav.test.tsx28
-rw-r--r--src/components/molecules/nav/nav.tsx85
-rw-r--r--src/components/molecules/nav/pagination.module.scss51
-rw-r--r--src/components/molecules/nav/pagination.stories.tsx171
-rw-r--r--src/components/molecules/nav/pagination.test.tsx26
-rw-r--r--src/components/molecules/nav/pagination.tsx220
12 files changed, 952 insertions, 0 deletions
diff --git a/src/components/molecules/nav/breadcrumb.module.scss b/src/components/molecules/nav/breadcrumb.module.scss
new file mode 100644
index 0000000..c26f60a
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.module.scss
@@ -0,0 +1,19 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ @extend %reset-ordered-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ gap: var(--spacing-2xs);
+}
+
+.item {
+ &:not(:last-of-type) {
+ &::after {
+ content: ">";
+ margin-left: var(--spacing-2xs);
+ }
+ }
+}
diff --git a/src/components/molecules/nav/breadcrumb.stories.tsx b/src/components/molecules/nav/breadcrumb.stories.tsx
new file mode 100644
index 0000000..cf67e60
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.stories.tsx
@@ -0,0 +1,81 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Breadcrumb from './breadcrumb';
+
+/**
+ * Breadcrumb - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Breadcrumb',
+ component: Breadcrumb,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Styles',
+ },
+ description: 'Set additional classnames to the nav element.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ itemClassName: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Styles',
+ },
+ description: 'Set additional classnames to the breadcrumb items.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ description: 'The breadcrumb items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof Breadcrumb>;
+
+const Template: ComponentStory<typeof Breadcrumb> = (args) => (
+ <Breadcrumb {...args} />
+);
+
+/**
+ * Breadcrumb Stories - One item
+ */
+export const OneItem = Template.bind({});
+OneItem.args = {
+ items: [{ id: 'home', url: '#', name: 'Home' }],
+};
+
+/**
+ * Breadcrumb Stories - Two items
+ */
+export const TwoItems = Template.bind({});
+TwoItems.args = {
+ items: [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ ],
+};
+
+/**
+ * Breadcrumb Stories - Three items
+ */
+export const ThreeItems = Template.bind({});
+ThreeItems.args = {
+ items: [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ { id: 'post1', url: '#', name: 'A Post' },
+ ],
+};
diff --git a/src/components/molecules/nav/breadcrumb.test.tsx b/src/components/molecules/nav/breadcrumb.test.tsx
new file mode 100644
index 0000000..43220c9
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@test-utils';
+import Breadcrumb, { type BreadcrumbItem } from './breadcrumb';
+
+const items: BreadcrumbItem[] = [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ { id: 'post1', url: '#', name: 'A Post' },
+];
+
+describe('Breadcrumb', () => {
+ it('renders a navigation', () => {
+ render(<Breadcrumb items={items} />);
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx
new file mode 100644
index 0000000..d184d65
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -0,0 +1,127 @@
+import Link from '@components/atoms/links/link';
+import { settings } from '@utils/config';
+import Script from 'next/script';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList, ListItem, WithContext } from 'schema-dts';
+import styles from './breadcrumb.module.scss';
+
+export type BreadcrumbItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * The item URL.
+ */
+ url: string;
+ /**
+ * The item name.
+ */
+ name: string;
+};
+
+export type BreadcrumbProps = {
+ /**
+ * Set additional classnames to the nav element.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the breadcrumb items.
+ */
+ itemClassName?: string;
+ /**
+ * The breadcrumb items
+ */
+ items: BreadcrumbItem[];
+};
+
+/**
+ * Breadcrumb component
+ *
+ * Render a breadcrumb navigation.
+ */
+const Breadcrumb: FC<BreadcrumbProps> = ({
+ itemClassName = '',
+ items,
+ ...props
+}) => {
+ const intl = useIntl();
+
+ const ariaLabel = intl.formatMessage({
+ defaultMessage: 'Breadcrumb',
+ description: 'Breadcrumb: an accessible name for the breadcrumb nav.',
+ id: '28nnDY',
+ });
+
+ /**
+ * Retrieve the breadcrumb list items.
+ *
+ * @param {BreadcrumbItem[]} list - The breadcrumb items.
+ * @returns {JSX.Element[]} The list items.
+ */
+ const getListItems = (list: BreadcrumbItem[]): JSX.Element[] => {
+ return list.map((item, index) => {
+ const isLastItem = index === list.length - 1;
+ const itemStyles = isLastItem
+ ? `${styles.item} screen-reader-text`
+ : styles.item;
+
+ return (
+ <li key={item.id} className={`${itemStyles} ${itemClassName}`}>
+ {isLastItem ? item.name : <Link href={item.url}>{item.name}</Link>}
+ </li>
+ );
+ });
+ };
+
+ /**
+ * Retrieve the breadcrumb list items with Schema.org format.
+ *
+ * @param {BreadcrumbItem[]} list - The breadcrumb items.
+ * @returns {ListItem[]} An array of list items using Schema.org format.
+ */
+ const getSchemaItems = (list: BreadcrumbItem[]): ListItem[] => {
+ const schemaItems: ListItem[] = [];
+
+ list.forEach((item, index) => {
+ schemaItems.push({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ });
+ });
+
+ return schemaItems;
+ };
+
+ const schemaJsonLd: WithContext<BreadcrumbList> = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ '@id': `${settings.url}/#breadcrumb`,
+ itemListElement: getSchemaItems(items),
+ };
+
+ return (
+ <>
+ <Script
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ />
+ <nav aria-label={ariaLabel} {...props}>
+ <span className="screen-reader-text">
+ {intl.formatMessage({
+ defaultMessage: 'You are here:',
+ description: 'Breadcrumb: You are here prefix',
+ id: '16zl9Z',
+ })}
+ </span>
+ <ol className={styles.list}>{getListItems(items)}</ol>
+ </nav>
+ </>
+ );
+};
+
+export default Breadcrumb;
diff --git a/src/components/molecules/nav/nav.module.scss b/src/components/molecules/nav/nav.module.scss
new file mode 100644
index 0000000..9c0f6de
--- /dev/null
+++ b/src/components/molecules/nav/nav.module.scss
@@ -0,0 +1,22 @@
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.nav {
+ &__list {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-2xs);
+ align-items: center;
+ }
+
+ &--footer & {
+ &__item:not(:first-child) {
+ &::before {
+ content: "\2022";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx
new file mode 100644
index 0000000..f3a29a6
--- /dev/null
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -0,0 +1,107 @@
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NavComponent, { type NavItem } from './nav';
+
+/**
+ * Nav - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Nav',
+ component: NavComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the navigation.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the navigation wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The nav items.',
+ type: {
+ name: 'other',
+ required: true,
+ value: '',
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The navigation kind.',
+ options: ['main', 'footer'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ listClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the navigation list.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof NavComponent>;
+
+const Template: ComponentStory<typeof NavComponent> = (args) => (
+ <NavComponent {...args} />
+);
+
+const MainNavItems: NavItem[] = [
+ { id: 'homeLink', href: '/', label: 'Home', logo: <Home /> },
+ { id: 'contactLink', href: '/contact', label: 'Contact', logo: <Envelop /> },
+];
+
+const FooterNavItems: NavItem[] = [
+ { id: 'contactLink', href: '/contact', label: 'Contact' },
+ { id: 'legalLink', href: '/legal-notice', label: 'Legal notice' },
+];
+
+/**
+ * Nav Stories - Main navigation
+ */
+export const MainNav = Template.bind({});
+MainNav.args = {
+ items: MainNavItems,
+ kind: 'main',
+};
+
+/**
+ * Nav Stories - Footer navigation
+ */
+export const FooterNav = Template.bind({});
+FooterNav.args = {
+ items: FooterNavItems,
+ kind: 'footer',
+};
diff --git a/src/components/molecules/nav/nav.test.tsx b/src/components/molecules/nav/nav.test.tsx
new file mode 100644
index 0000000..183ca0b
--- /dev/null
+++ b/src/components/molecules/nav/nav.test.tsx
@@ -0,0 +1,28 @@
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import { render, screen } from '@test-utils';
+import Nav, { type NavItem } from './nav';
+
+const navItems: NavItem[] = [
+ { id: 'homeLink', href: '/', label: 'Home', logo: <Home /> },
+ { id: 'contactLink', href: '/contact', label: 'Contact', logo: <Envelop /> },
+];
+
+describe('Nav', () => {
+ it('renders a main navigation', () => {
+ render(<Nav kind="main" items={navItems} />);
+ expect(screen.getByRole('navigation')).toHaveClass('nav--main');
+ });
+
+ it('renders a footer navigation', () => {
+ render(<Nav kind="footer" items={navItems} />);
+ expect(screen.getByRole('navigation')).toHaveClass('nav--footer');
+ });
+
+ it('renders navigation links', () => {
+ render(<Nav kind="main" items={navItems} />);
+ expect(
+ screen.getByRole('link', { name: navItems[0].label })
+ ).toHaveAttribute('href', navItems[0].href);
+ });
+});
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
new file mode 100644
index 0000000..581f813
--- /dev/null
+++ b/src/components/molecules/nav/nav.tsx
@@ -0,0 +1,85 @@
+import Link from '@components/atoms/links/link';
+import NavLink from '@components/atoms/links/nav-link';
+import { FC, ReactNode } from 'react';
+import styles from './nav.module.scss';
+
+export type NavItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * The item link.
+ */
+ href: string;
+ /**
+ * The item name.
+ */
+ label: string;
+ /**
+ * The item logo.
+ */
+ logo?: ReactNode;
+};
+
+export type NavProps = {
+ /**
+ * An accessible name.
+ */
+ 'aria-label'?: string;
+ /**
+ * Set additional classnames to the navigation wrapper.
+ */
+ className?: string;
+ /**
+ * The navigation items.
+ */
+ items: NavItem[];
+ /**
+ * The navigation kind.
+ */
+ kind: 'main' | 'footer';
+ /**
+ * Set additional classnames to the navigation list.
+ */
+ listClassName?: string;
+};
+
+/**
+ * Nav component
+ *
+ * Render the nav links.
+ */
+const Nav: FC<NavProps> = ({
+ className = '',
+ items,
+ kind,
+ listClassName = '',
+ ...props
+}) => {
+ const kindClass = `nav--${kind}`;
+
+ /**
+ * Get the nav items.
+ * @returns {JSX.Element[]} An array of nav items.
+ */
+ const getItems = (): JSX.Element[] => {
+ return items.map(({ id, href, label, logo }) => (
+ <li key={id} className={styles.nav__item}>
+ {kind === 'main' ? (
+ <NavLink href={href} label={label} logo={logo} />
+ ) : (
+ <Link href={href}>{label}</Link>
+ )}
+ </li>
+ ));
+ };
+
+ return (
+ <nav className={`${styles[kindClass]} ${className}`} {...props}>
+ <ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul>
+ </nav>
+ );
+};
+
+export default Nav;
diff --git a/src/components/molecules/nav/pagination.module.scss b/src/components/molecules/nav/pagination.module.scss
new file mode 100644
index 0000000..56c5bfc
--- /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-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
new file mode 100644
index 0000000..2e86db4
--- /dev/null
+++ b/src/components/molecules/nav/pagination.stories.tsx
@@ -0,0 +1,171 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PaginationComponent from './pagination';
+
+/**
+ * Pagination - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Pagination',
+ component: PaginationComponent,
+ 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 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..934b50a
--- /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 = 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[] => {
+ 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} ${styles['list--pages']}`}>
+ {getPages(current, totalPages)}
+ </ul>
+ <ul className={styles.list}>
+ {hasPreviousPage &&
+ getItem('previous', previousPageName, previousPageUrl)}
+ {hasNextPage && getItem('next', nextPageName, nextPageUrl)}
+ </ul>
+ </nav>
+ );
+};
+
+export default Pagination;