From 4c021d10c4041b21b9f2f5e433aed86e5c257dd6 Mon Sep 17 00:00:00 2001 From: Jacob Capps <99674188+jcbcapps@users.noreply.github.com> Date: Fri, 23 Sep 2022 10:49:25 -0500 Subject: [PATCH] feat: Add Pagination component (#2188) * Add Pagination component * Add test * Update component to use Link * Export component * Update controls for totalPages * Add optional callbacks * Update sandbox to use optional callbacks * Add onClick for clicking on a page number * Add test for onClick handlers --- .../Pagination/Pagination.stories.tsx | 112 +++++++ src/components/Pagination/Pagination.test.tsx | 304 ++++++++++++++++++ src/components/Pagination/Pagination.tsx | 230 +++++++++++++ src/index.ts | 1 + 4 files changed, 647 insertions(+) create mode 100644 src/components/Pagination/Pagination.stories.tsx create mode 100644 src/components/Pagination/Pagination.test.tsx create mode 100644 src/components/Pagination/Pagination.tsx diff --git a/src/components/Pagination/Pagination.stories.tsx b/src/components/Pagination/Pagination.stories.tsx new file mode 100644 index 0000000000..a386e1d167 --- /dev/null +++ b/src/components/Pagination/Pagination.stories.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react' +import { ComponentMeta, ComponentStory } from '@storybook/react' +import { Pagination } from './Pagination' + +export default { + title: 'Components/Pagination', + component: Pagination, + argTypes: { + currentPage: { control: 'number' }, + maxSlots: { control: 'number' }, + pathname: { control: 'string' }, + totalPages: { control: 'number' }, + }, +} as ComponentMeta + +const pathname = '/test-pathname' +const Template: ComponentStory = (args) => { + const [current, setCurrentPage] = useState(args.currentPage) + + useEffect(() => { + if (args.currentPage >= args.totalPages) { + return + } + setCurrentPage(args.currentPage) + }, [args.currentPage]) + + const handleNext = () => { + const nextPage = current + 1 + setCurrentPage(nextPage) + } + + const handlePrevious = () => { + const prevPage = current - 1 + setCurrentPage(prevPage) + } + + const handlePageNumber = ( + event: React.MouseEvent, + pageNum: number + ) => { + setCurrentPage(pageNum) + } + + return ( + + ) +} + +export const Sandbox = Template.bind({}) +Sandbox.args = { + currentPage: 10, + maxSlots: 7, +} + +export const Default = (): React.ReactElement => ( + +) + +export const ThreePagesFirst = (): React.ReactElement => ( + +) +export const ThreePages = (): React.ReactElement => ( + +) +export const ThreePagesLast = (): React.ReactElement => ( + +) + +export const SevenPages = (): React.ReactElement => ( + +) + +export const EightPagesFirst = (): React.ReactElement => ( + +) + +export const EightPagesFour = (): React.ReactElement => ( + +) + +export const EightPagesFive = (): React.ReactElement => ( + +) + +export const EightPagesSix = (): React.ReactElement => ( + +) + +export const EightPagesLast = (): React.ReactElement => ( + +) + +export const NinePagesFive = (): React.ReactElement => ( + +) + +export const TenSlots = (): React.ReactElement => ( + +) diff --git a/src/components/Pagination/Pagination.test.tsx b/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 0000000000..72cdde11ff --- /dev/null +++ b/src/components/Pagination/Pagination.test.tsx @@ -0,0 +1,304 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' + +import { Pagination } from './Pagination' + +describe('Pagination component', () => { + const testPages = 24 + const testThreePages = 3 + const testSevenPages = 7 + const testPathname = '/test-pathname' + + it('renders pagination for a list of pages', () => { + render( + + ) + expect(screen.getByRole('navigation')).toBeInTheDocument() + + expect(screen.getByLabelText('Previous page')).toHaveAttribute( + 'href', + `${testPathname}?page=9` + ) + expect(screen.getByLabelText('Page 1')).toHaveAttribute( + 'href', + `${testPathname}?page=1` + ) + expect(screen.getByLabelText('Page 9')).toHaveAttribute( + 'href', + `${testPathname}?page=9` + ) + expect(screen.getByLabelText('Page 10')).toHaveAttribute( + 'href', + `${testPathname}?page=10` + ) + expect(screen.getByLabelText('Page 10')).toHaveAttribute( + 'aria-current', + 'page' + ) + expect(screen.getByLabelText('Page 11')).toHaveAttribute( + 'href', + `${testPathname}?page=11` + ) + expect(screen.getByLabelText('Page 24')).toHaveAttribute( + 'href', + `${testPathname}?page=24` + ) + expect(screen.getByLabelText('Next page')).toHaveAttribute( + 'href', + `${testPathname}?page=11` + ) + }) + + it('only renders the maximum number of slots', () => { + render( + + ) + expect(screen.getAllByRole('listitem')).toHaveLength(7) // overflow slots don't count + }) + + it('renders pagination when the first page is current', () => { + render( + + ) + expect(screen.queryByLabelText('Previous page')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Next page')).toBeInTheDocument() + expect(screen.getByLabelText('Page 1')).toHaveAttribute( + 'aria-current', + 'page' + ) + }) + + it('renders pagination when the last page is current', () => { + render( + + ) + expect(screen.queryByLabelText('Previous page')).toBeInTheDocument() + expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument() + expect(screen.getByLabelText('Page 24')).toHaveAttribute( + 'aria-current', + 'page' + ) + }) + + it('renders overflow at the beginning and end when current page is in the middle', () => { + render( + + ) + expect(screen.getByLabelText('Previous page')).toHaveAttribute( + 'href', + `${testPathname}?page=9` + ) + expect(screen.getByLabelText('Page 1')).toHaveAttribute( + 'href', + `${testPathname}?page=1` + ) + expect(screen.getByLabelText('Page 9')).toHaveAttribute( + 'href', + `${testPathname}?page=9` + ) + expect(screen.getByLabelText('Page 10')).toHaveAttribute( + 'href', + `${testPathname}?page=10` + ) + expect(screen.getByLabelText('Page 10')).toHaveAttribute( + 'aria-current', + 'page' + ) + expect(screen.getByLabelText('Page 11')).toHaveAttribute( + 'href', + `${testPathname}?page=11` + ) + expect(screen.getByLabelText('Page 24')).toHaveAttribute( + 'href', + `${testPathname}?page=24` + ) + expect(screen.getByLabelText('Next page')).toHaveAttribute( + 'href', + `${testPathname}?page=11` + ) + expect(screen.getAllByText('…')).toHaveLength(2) + }) + + it('renders overflow at the end when at the beginning of the pages', () => { + render( + + ) + + expect(screen.getByLabelText('Previous page')).toHaveAttribute( + 'href', + `${testPathname}?page=2` + ) + expect(screen.getByLabelText('Page 1')).toHaveAttribute( + 'href', + `${testPathname}?page=1` + ) + expect(screen.getByLabelText('Page 2')).toHaveAttribute( + 'href', + `${testPathname}?page=2` + ) + expect(screen.getByLabelText('Page 3')).toHaveAttribute( + 'href', + `${testPathname}?page=3` + ) + expect(screen.getByLabelText('Page 3')).toHaveAttribute( + 'aria-current', + 'page' + ) + expect(screen.getByLabelText('Page 4')).toHaveAttribute( + 'href', + `${testPathname}?page=4` + ) + expect(screen.getByLabelText('Page 5')).toHaveAttribute( + 'href', + `${testPathname}?page=5` + ) + expect(screen.getByLabelText('Page 24')).toHaveAttribute( + 'href', + `${testPathname}?page=24` + ) + expect(screen.getByLabelText('Next page')).toHaveAttribute( + 'href', + `${testPathname}?page=4` + ) + expect(screen.getAllByText('…')).toHaveLength(1) + }) + + it('renders overflow at the beginning when at the end of the pages', () => { + render( + + ) + + expect(screen.getByLabelText('Previous page')).toHaveAttribute( + 'href', + `${testPathname}?page=20` + ) + expect(screen.getByLabelText('Page 1')).toHaveAttribute( + 'href', + `${testPathname}?page=1` + ) + expect(screen.getByLabelText('Page 20')).toHaveAttribute( + 'href', + `${testPathname}?page=20` + ) + expect(screen.getByLabelText('Page 21')).toHaveAttribute( + 'href', + `${testPathname}?page=21` + ) + expect(screen.getByLabelText('Page 21')).toHaveAttribute( + 'aria-current', + 'page' + ) + expect(screen.getByLabelText('Page 22')).toHaveAttribute( + 'href', + `${testPathname}?page=22` + ) + expect(screen.getByLabelText('Page 23')).toHaveAttribute( + 'href', + `${testPathname}?page=23` + ) + expect(screen.getByLabelText('Page 24')).toHaveAttribute( + 'href', + `${testPathname}?page=24` + ) + expect(screen.getByLabelText('Next page')).toHaveAttribute( + 'href', + `${testPathname}?page=22` + ) + expect(screen.getAllByText('…')).toHaveLength(1) + }) + + it('can click onClickNext, onClickPrevious and onClickPagenumber', () => { + const mockOnClickNext = jest.fn() + const mockOnClickPrevious = jest.fn() + const mockOnClickPageNumber = jest.fn() + + const { getByTestId, getAllByTestId } = render( + + ) + + fireEvent.click(getByTestId('pagination-next')) + expect(mockOnClickNext).toHaveBeenCalledTimes(1) + + fireEvent.click(getByTestId('pagination-previous')) + expect(mockOnClickPrevious).toHaveBeenCalledTimes(1) + + const allPageNumbers = getAllByTestId('pagination-page-number') + fireEvent.click(allPageNumbers[0]) + expect(mockOnClickPageNumber).toHaveBeenCalledTimes(1) + }) + + describe('for fewer pages than the max slots', () => { + it('renders pagination with no overflow', () => { + render( + + ) + expect(screen.getAllByRole('listitem')).toHaveLength(5) + expect(screen.queryAllByText('…')).toHaveLength(0) + }) + + it('renders pagination with no overflow', () => { + render( + + ) + expect(screen.getAllByRole('listitem')).toHaveLength(9) + expect(screen.queryAllByText('…')).toHaveLength(0) + }) + }) + + describe('with a custom slot number passed in', () => { + it('only renders the maximum number of slots', () => { + render( + + ) + expect(screen.getAllByRole('listitem')).toHaveLength(10) + }) + }) +}) diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000000..78637283a0 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,230 @@ +import React from 'react' +import classnames from 'classnames' +import { Icon } from '../Icon/Icons' +import { Link } from '../Link/Link' +import { Button } from '../Button/Button' + +type PaginationProps = { + pathname: string // pathname of results page + totalPages: number // total items divided by items per page + currentPage: number // current page number (starting at 1) + maxSlots?: number // number of pagination "slots" + onClickNext?: () => void + onClickPrevious?: () => void + onClickPageNumber?: ( + event: React.MouseEvent, + page: number + ) => void +} + +const PaginationPage = ({ + page, + isCurrent, + pathname, + onClickPageNumber, +}: { + pathname: string + page: number + isCurrent?: boolean + onClickPageNumber?: ( + event: React.MouseEvent, + page: number + ) => void +}) => { + const linkClasses = classnames('usa-pagination__button', { + 'usa-current': isCurrent, + }) + + return ( +
  • + {onClickPageNumber ? ( + + ) : ( + + {page} + + )} +
  • + ) +} + +const PaginationOverflow = () => ( +
  • + +
  • +) + +export const Pagination = ({ + pathname, + totalPages, + currentPage, + className, + maxSlots = 7, + onClickPrevious, + onClickNext, + onClickPageNumber, + ...props +}: PaginationProps & JSX.IntrinsicElements['nav']): React.ReactElement => { + const navClasses = classnames('usa-pagination', className) + + const isOnFirstPage = currentPage === 1 + const isOnLastPage = currentPage === totalPages + + const showOverflow = totalPages > maxSlots // If more pages than slots, use overflow indicator(s) + + const middleSlot = Math.round(maxSlots / 2) // 4 if maxSlots is 7 + const showPrevOverflow = showOverflow && currentPage > middleSlot + const showNextOverflow = + showOverflow && totalPages - currentPage >= middleSlot + + // Assemble array of page numbers to be shown + const currentPageRange: Array = showOverflow + ? [currentPage] + : Array.from({ length: totalPages }).map((_, i) => i + 1) + + if (showOverflow) { + // Determine range of pages to show based on current page & number of slots + // Follows logic described at: https://designsystem.digital.gov/components/pagination/ + const prevSlots = isOnFirstPage ? 0 : showPrevOverflow ? 2 : 1 // first page + prev overflow + const nextSlots = isOnLastPage ? 0 : showNextOverflow ? 2 : 1 // next overflow + last page + const pageRangeSize = maxSlots - 1 - (prevSlots + nextSlots) // remaining slots to show (minus one for the current page) + + // Determine how many slots we have before/after the current page + let currentPageBeforeSize = 0 + let currentPageAfterSize = 0 + if (showPrevOverflow && showNextOverflow) { + // We are in the middle of the set, there will be overflow (...) at both the beginning & end + // Ex: [1] [...] [9] [10] [11] [...] [24] + currentPageBeforeSize = Math.round((pageRangeSize - 1) / 2) + currentPageAfterSize = pageRangeSize - currentPageBeforeSize + } else if (showPrevOverflow) { + // We are in the end of the set, there will be overflow (...) at the beginning + // Ex: [1] [...] [20] [21] [22] [23] [24] + currentPageAfterSize = totalPages - currentPage - 1 // current & last + currentPageAfterSize = currentPageAfterSize < 0 ? 0 : currentPageAfterSize + currentPageBeforeSize = pageRangeSize - currentPageAfterSize + } else if (showNextOverflow) { + // We are in the beginning of the set, there will be overflow (...) at the end + // Ex: [1] [2] [3] [4] [5] [...] [24] + currentPageBeforeSize = currentPage - 2 // first & current + currentPageBeforeSize = + currentPageBeforeSize < 0 ? 0 : currentPageBeforeSize + currentPageAfterSize = pageRangeSize - currentPageBeforeSize + } + + // Populate the remaining slots + let counter = 1 + while (currentPageBeforeSize > 0) { + // Add previous pages before the current page + currentPageRange.unshift(currentPage - counter) + counter++ + currentPageBeforeSize-- + } + + counter = 1 + while (currentPageAfterSize > 0) { + // Add subsequent pages after the current page + currentPageRange.push(currentPage + counter) + counter++ + currentPageAfterSize-- + } + + // Add prev/next overflow indicators, and first/last pages as needed + if (showPrevOverflow) currentPageRange.unshift('overflow') + if (currentPage !== 1) currentPageRange.unshift(1) + if (showNextOverflow) currentPageRange.push('overflow') + if (currentPage !== totalPages) currentPageRange.push(totalPages) + } + + const prevPage = !isOnFirstPage && currentPage - 1 + const nextPage = !isOnLastPage && currentPage + 1 + + return ( + + ) +} diff --git a/src/index.ts b/src/index.ts index 0a196beb0d..a22bd8edd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { Table } from './components/Table/Table' export { Tag } from './components/Tag/Tag' export { Tooltip } from './components/Tooltip/Tooltip' export { SideNav } from './components/SideNav/SideNav' +export { Pagination } from './components/Pagination/Pagination' /** Collection components */ export { Collection } from './components/Collection/Collection'