src/app/components/Pagination/index.tsx

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
/** @jsx jsx */
import { jsx } from '@emotion/react';
import { PropsWithChildren, useContext } from 'react';
import {
  GROUP_2_MIN_WIDTH_BP,
  GROUP_2_MAX_WIDTH_BP,
  GROUP_3_MIN_WIDTH_BP,
  GROUP_3_MAX_WIDTH_BP,
  GROUP_4_MIN_WIDTH_BP,
} from '#app/components/ThemeProvider/mediaQueries';
import { RequestContext } from '#app/contexts/RequestContext';
import { LIVE_PAGE } from '#app/routes/utils/pageTypes';
import { Direction } from '#app/models/types/global';
import { ServiceContext } from '../../contexts/ServiceContext';
import buildBlocks, { VISIBILITY } from './buildBlocks';
import { Ellipsis, LeftChevron, RightChevron } from '../icons';
import VisuallyHiddenText from '../VisuallyHiddenText';
import styles from './index.styles';

interface LinkComponentProps {
  pageNumber: number;
  isActive?: boolean;
}

interface ArrowProps {
  activePage: number;
  dir: Direction;
}

interface RenderBlockProps {
  type: string;
  pageNumber: number;
  key: number;
  visibility: string;
  activePage: number;
}

interface PaginationProps {
  activePage?: number;
  pageCount?: number;
  pageXOfY: string;
  previousPage: string;
  nextPage: string;
  page: string;
}

const visibilityToMediaQuery = (visibility: string) =>
  ({
    [VISIBILITY.MOBILE_ONLY]: `display: none; @media (min-width: ${GROUP_2_MIN_WIDTH_BP}rem) and (max-width: ${GROUP_2_MAX_WIDTH_BP}rem) {
      display: inline-block;
    }`,
    [VISIBILITY.TABLET_DOWN]: `display: none; @media (max-width: ${GROUP_3_MAX_WIDTH_BP}rem) {
      display: inline-block;
    }`,
    [VISIBILITY.TABLET_UP]: `display: none; @media (min-width: ${GROUP_3_MIN_WIDTH_BP}rem) {
      display: inline-block;
    }`,
    [VISIBILITY.DESKTOP_ONLY]: `display: none; @media (min-width: ${GROUP_4_MIN_WIDTH_BP}rem) {
      display: inline-block;
    }`,
    [VISIBILITY.ALL]: `display: inline-block;`,
  })[visibility] || 'display: none;';

const LinkComponent = ({
  children,
  pageNumber,
  isActive,
  ...rest
}: PropsWithChildren<LinkComponentProps>) => (
  <a
    css={isActive ? styles.activeA : styles.inactiveA}
    href={`?page=${pageNumber}`}
    className="focusIndicatorOutlineBlack"
    {...(isActive && { 'aria-current': 'page' })}
    {...rest}
  >
    {children}
  </a>
);

const PreviousArrow = ({
  activePage,
  children,
  dir,
}: PropsWithChildren<ArrowProps>) => (
  <span css={() => [styles.block, visibilityToMediaQuery(VISIBILITY.ALL)]}>
    <LinkComponent
      pageNumber={activePage - 1}
      aria-labelledby="pagination-previous-page"
    >
      <span id="pagination-previous-page">
        {dir === 'ltr' ? <LeftChevron /> : <RightChevron />}
        <VisuallyHiddenText>{children}</VisuallyHiddenText>
      </span>
    </LinkComponent>
  </span>
);

const NextArrow = ({
  activePage,
  children,
  dir,
}: PropsWithChildren<ArrowProps>) => (
  <span css={() => [styles.block, visibilityToMediaQuery(VISIBILITY.ALL)]}>
    <LinkComponent
      pageNumber={activePage + 1}
      aria-labelledby="pagination-next-page"
    >
      <span id="pagination-next-page">
        <VisuallyHiddenText>{children}</VisuallyHiddenText>
        {dir === 'ltr' ? <RightChevron /> : <LeftChevron />}
      </span>
    </LinkComponent>
  </span>
);

const renderBlock = ({
  activePage,
  key,
  type,
  pageNumber,
  visibility,
}: RenderBlockProps) => {
  if (type === 'NUMBER') {
    return (
      <li
        css={() => [styles.block, visibilityToMediaQuery(visibility)]}
        key={key}
      >
        <LinkComponent
          isActive={pageNumber === activePage}
          pageNumber={pageNumber}
        >
          {pageNumber}
        </LinkComponent>
      </li>
    );
  }

  return (
    <li
      css={() => [styles.elipsisBlock, visibilityToMediaQuery(visibility)]}
      role="separator"
      data-testid="topic-pagination-ellipsis"
      key={key}
    >
      <Ellipsis />
    </li>
  );
};

const Pagination = ({
  activePage = 1,
  pageCount = 1,
  pageXOfY = 'Page {x} of {y}',
  previousPage = 'Previous Page',
  nextPage = 'Next Page',
  page = 'Page',
}: PaginationProps) => {
  const { dir } = useContext(ServiceContext);
  const { pageType } = useContext(RequestContext);
  const blocks = buildBlocks(activePage, pageCount);
  const isLive = pageType === LIVE_PAGE;
  if (!blocks) return null;
  const tokenMapper = (token: string, key: number) =>
    ({
      '{x}': <b key={key}>{activePage}</b>,
      '{y}': <b key={key}>{pageCount}</b>,
    })[token] || <span key={key}>{token}</span>;

  const tokens = pageXOfY.split(/(\{.\})/).map(tokenMapper);

  const showPreviousArrow = activePage > 1;
  const showNextArrow = activePage < pageCount;

  return (
    <nav
      css={[styles.nav, isLive && styles.liveNavMargin]}
      role="navigation"
      aria-label={page}
      data-testid="topic-pagination"
    >
      {showPreviousArrow && (
        <PreviousArrow activePage={activePage} dir={dir}>
          {previousPage}
        </PreviousArrow>
      )}
      <div
        css={styles.textSummary}
        data-testid="topic-pagination-summary"
        // eslint-disable-next-line jsx-a11y/aria-role
        role="text"
      >
        {tokens}
      </div>
      <ul css={styles.unorderedList} role="list">
        {blocks.map(block => renderBlock({ ...block, activePage }))}
      </ul>
      {showNextArrow && (
        <NextArrow activePage={activePage} dir={dir}>
          {nextPage}
        </NextArrow>
      )}
    </nav>
  );
};

export default Pagination;