aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms/images/gallery.test.tsx
blob: bffc3b24458f9c62a214d0406c34afc4bf4b616f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { describe, expect, it } from '@jest/globals';
import { render, screen as rtlScreen } from '@testing-library/react';
import NextImage from 'next/image';
import { Gallery } from './gallery';

const columns = 3;

const image = {
  alt: 'Modi provident omnis',
  height: 480,
  src: 'http://picsum.photos/640/480',
  width: 640,
};

describe('Gallery', () => {
  it('renders the correct number of items', () => {
    render(
      <Gallery columns={columns}>
        <NextImage {...image} />
        <NextImage {...image} />
        <NextImage {...image} />
        <NextImage {...image} />
      </Gallery>
    );

    // eslint-disable-next-line @typescript-eslint/no-magic-numbers
    expect(rtlScreen.getAllByRole('listitem')).toHaveLength(4);
  });

  it('renders the right number of columns', () => {
    render(
      <Gallery columns={columns}>
        <NextImage {...image} />
        <NextImage {...image} />
        <NextImage {...image} />
        <NextImage {...image} />
      </Gallery>
    );
    expect(rtlScreen.getByRole('list')).toHaveClass(
      `wrapper--${columns}-columns`
    );
  });
});
/* Comment.PreprocFile */ .highlight .c1 { color: #888888 } /* Comment.Single */ .highlight .cs { color: #cc0000; font-weight: bold; background-color: #fff0f0 } /* Comment.Special */ .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ .highlight .ge { font-style: italic } /* Generic.Emph */ .highlight .gr { color: #aa0000 } /* Generic.Error */ .highlight .gh { color: #333333 } /* Generic.Heading */ .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ .highlight .go { color: #888888 } /* Generic.Output */ .highlight .gp { color: #555555 } /* Generic.Prompt */ .highlight .gs { font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #666666 } /* Generic.Subheading */ .highlight .gt { color: #aa0000 } /* Generic.Traceback */ .highlight .kc { color: #008800; font-weight: bold } /* Keyword.Constant */ .highlight .kd { color: #008800; font-weight: bold } /* Keyword.Declaration */ .highlight .kn { color: #008800; font-weight: bold } /* Keyword.Namespace */ .highlight .kp { color: #008800 } /* Keyword.Pseudo */ .highlight .kr { color: #008800; font-weight: bold } /* Keyword.Reserved */ .highlight .kt { color: #888888; font-weight: bold } /* Keyword.Type */ .highlight .m { color: #0000DD; font-weight: bold } /* Literal.Number */ .highlight .s { color: #dd2200; background-color: #fff0f0 } /* Literal.String */ .highlight .na { color: #336699 } /* Name.Attribute */ .highlight .nb { color: #003388 } /* Name.Builtin */ .highlight .nc { color: #bb0066; font-weight: bold } /* Name.Class */ .highlight .no { color: #003366; font-weight: bold } /* Name.Constant */ .highlight .nd { color: #555555 } /* Name.Decorator */ .highlight .ne { color: #bb0066; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #0066bb; font-weight: bold } /* Name.Function */ .highlight .nl { color: #336699; font-style: italic } /* Name.Label */ .highlight .nn { color: #bb0066; font-weight: bold } /* Name.Namespace */ .highlight .py { color: #336699; font-weight: bold } /* Name.Property */ .highlight .nt { color: #bb0066; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #336699 } /* Name.Variable */ .highlight .ow { color: #008800 } /* Operator.Word */ .highlight .w { color: #bbbbbb } /* Text.Whitespace */ .highlight .mb { color: #0000DD; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #0000DD; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #0000DD; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #0000DD; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #0000DD; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Affix */ .highlight .sb { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Backtick */ .highlight .sc { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Char */ .highlight .dl { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Delimiter */ .highlight .sd { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Doc */ .highlight .s2 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Double */ .highlight .se { color: #0044dd; background-color: #fff0f0 } /* Literal.String.Escape */ .highlight .sh { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Heredoc */ .highlight .si { color: #3333bb; background-color: #fff0f0 } /* Literal.String.Interpol */ .highlight .sx { color: #22bb22; background-color: #f0fff0 } /* Literal.String.Other */ .highlight .sr { color: #008800; background-color: #fff0ff } /* Literal.String.Regex */ .highlight .s1 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Single */ .highlight .ss { color: #aa6600; background-color: #fff0f0 } /* Literal.String.Symbol */ .highlight .bp { color: #003388 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #0066bb; font-weight: bold } /* Name.Function.Magic */ .highlight .vc { color: #336699 } /* Name.Variable.Class */ .highlight .vg { color: #dd7700 } /* Name.Variable.Global */ .highlight .vi { color: #3333bb } /* Name.Variable.Instance */ .highlight .vm { color: #336699 } /* Name.Variable.Magic */ .highlight .il { color: #0000DD; font-weight: bold } /* Literal.Number.Integer.Long */
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;