diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-04-21 17:58:36 +0200 | 
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-04-21 17:58:36 +0200 | 
| commit | 3a68d155afd4559e141bcb6c1ca3d833d3bd4667 (patch) | |
| tree | 00517d3567fe1f864f5d463dca3fb6c17cfe01b3 /src/components/molecules | |
| parent | 34502bd004c2522a8f2a217da3adf51586d1dec3 (diff) | |
chore: add a Pagination component
Diffstat (limited to 'src/components/molecules')
| -rw-r--r-- | src/components/molecules/nav/pagination.module.scss | 51 | ||||
| -rw-r--r-- | src/components/molecules/nav/pagination.stories.tsx | 175 | ||||
| -rw-r--r-- | src/components/molecules/nav/pagination.test.tsx | 26 | ||||
| -rw-r--r-- | src/components/molecules/nav/pagination.tsx | 220 | 
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; | 
