diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 5ae6725434..e69dde9960 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -117,6 +117,9 @@
"github_too_many_files_error": "",
"github_validation_check": "",
"give_feedback": "",
+ "go_next_page": "",
+ "go_page": "",
+ "go_prev_page": "",
"go_to_error_location": "",
"have_an_extra_backup": "",
"headers": "",
@@ -211,6 +214,8 @@
"other_logs_and_files": "",
"other_output_files": "",
"owner": "",
+ "page_current": "",
+ "pagination_navigation": "",
"pdf_compile_in_progress_error": "",
"pdf_compile_rate_limit_hit": "",
"pdf_compile_try_again": "",
diff --git a/services/web/frontend/js/shared/components/pagination.js b/services/web/frontend/js/shared/components/pagination.js
new file mode 100644
index 0000000000..117dfb34fa
--- /dev/null
+++ b/services/web/frontend/js/shared/components/pagination.js
@@ -0,0 +1,170 @@
+import React, { useMemo } from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import { useTranslation } from 'react-i18next'
+
+function Pagination({ currentPage, totalPages, handlePageClick }) {
+ const { t } = useTranslation()
+
+ const maxOtherPageButtons = useMemo(() => {
+ let maxOtherPageButtons = 4 // does not include current page, prev/next buttons
+ if (totalPages < maxOtherPageButtons + 1) {
+ maxOtherPageButtons = totalPages - 1
+ }
+ return maxOtherPageButtons
+ }, [totalPages])
+
+ const pageButtons = useMemo(() => {
+ const result = []
+ let nextPage = currentPage + 1
+ let prevPage = currentPage - 1
+
+ function calcPages() {
+ if (nextPage && nextPage <= totalPages) {
+ result.push(nextPage)
+ nextPage++
+ } else {
+ nextPage = undefined
+ }
+
+ if (prevPage && prevPage > 0) {
+ result.push(prevPage)
+ prevPage--
+ } else {
+ prevPage = undefined
+ }
+ }
+
+ while (result.length < maxOtherPageButtons) {
+ calcPages()
+ }
+
+ result.push(currentPage) // wait until prev/next calculated to add current
+ result.sort((a, b) => a - b) // sort numerically
+
+ return result
+ }, [currentPage, totalPages, maxOtherPageButtons])
+
+ const morePrevPages = useMemo(() => {
+ return pageButtons[0] !== 1 && currentPage - maxOtherPageButtons / 2 > 1
+ }, [pageButtons, currentPage, maxOtherPageButtons])
+
+ const moreNextPages = useMemo(() => {
+ return pageButtons[pageButtons.length - 1] < totalPages
+ }, [pageButtons, totalPages])
+
+ return (
+
+ )
+}
+
+function PaginationItem({ page, currentPage, handlePageClick }) {
+ const { t } = useTranslation()
+ const itemClassName = classNames({ active: currentPage === page })
+ const ariaCurrent = currentPage === page
+ const ariaLabel =
+ currentPage === page ? t('page_current', { page }) : t('go_page', { page })
+ return (
+
+
+
+ )
+}
+
+function isPositiveNumber(value) {
+ return typeof value === 'number' && value > 0
+}
+
+function isCurrentPageWithinTotalPages(currentPage, totalPages) {
+ return currentPage <= totalPages
+}
+
+Pagination.propTypes = {
+ currentPage: function (props, propName, componentName) {
+ if (
+ !isPositiveNumber(props[propName]) ||
+ !isCurrentPageWithinTotalPages(props.currentPage, props.totalPages)
+ ) {
+ return new Error(
+ 'Invalid prop `' +
+ propName +
+ '` supplied to' +
+ ' `' +
+ componentName +
+ '`. Validation failed.'
+ )
+ }
+ },
+ totalPages: function (props, propName, componentName) {
+ if (!isPositiveNumber(props[propName])) {
+ return new Error(
+ 'Invalid prop `' +
+ propName +
+ '` supplied to' +
+ ' `' +
+ componentName +
+ '`. Validation failed.'
+ )
+ }
+ },
+ handlePageClick: PropTypes.func.isRequired,
+}
+
+PaginationItem.propTypes = {
+ currentPage: PropTypes.number.isRequired,
+ page: PropTypes.number.isRequired,
+ handlePageClick: PropTypes.func.isRequired,
+}
+
+export default Pagination
diff --git a/services/web/frontend/stories/pagination.stories.js b/services/web/frontend/stories/pagination.stories.js
new file mode 100644
index 0000000000..c50ff6728b
--- /dev/null
+++ b/services/web/frontend/stories/pagination.stories.js
@@ -0,0 +1,21 @@
+import React from 'react'
+
+import Pagination from '../js/shared/components/pagination'
+
+export const Interactive = args => {
+ return
+}
+
+export default {
+ title: 'Pagination',
+ component: Pagination,
+ args: {
+ currentPage: 1,
+ totalPages: 10,
+ handlePageClick: () => {},
+ },
+ argTypes: {
+ currentPage: { control: { type: 'number', min: 1, max: 10, step: 1 } },
+ totalPages: { control: { disable: true } },
+ },
+}
diff --git a/services/web/frontend/stylesheets/components/pagination.less b/services/web/frontend/stylesheets/components/pagination.less
index db047389c7..ebf00f7f9e 100755
--- a/services/web/frontend/stylesheets/components/pagination.less
+++ b/services/web/frontend/stylesheets/components/pagination.less
@@ -10,6 +10,7 @@
> li {
display: inline; // Remove list-style and block-level defaults
> a,
+ > button,
> span {
position: relative;
float: left; // Collapse white-space
@@ -23,6 +24,7 @@
}
&:first-child {
> a,
+ > button,
> span {
margin-left: 0;
.border-left-radius(@border-radius-base);
@@ -30,6 +32,7 @@
}
&:last-child {
> a,
+ > button,
> span {
.border-right-radius(@border-radius-base);
}
@@ -37,6 +40,7 @@
}
> li > a,
+ > li > button,
> li > span {
&:hover,
&:focus {
@@ -47,6 +51,7 @@
}
> .active > a,
+ > .active > button,
> .active > span {
&,
&:hover,
@@ -65,13 +70,20 @@
> span:focus,
> a,
> a:hover,
- > a:focus {
+ > a:focus,
+ > button,
+ > button:hover,
+ > button:focus {
color: @pagination-disabled-color;
background-color: @pagination-disabled-bg;
border-color: @pagination-disabled-border;
cursor: not-allowed;
}
}
+
+ .ellipses {
+ pointer-events: none;
+ }
}
// Sizing
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 1d7ec7153c..b9e8296e9f 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1461,5 +1461,10 @@
"cancel_anytime": "We're confident that you'll love __appName__, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.",
"for_visa_mastercard_and_discover": "For <0>Visa, MasterCard and Discover0>, the <1>3 digits1> on the <2>back2> of your card.",
"for_american_express": "For <0>American Express0>, the <1>4 digits1> on the <2>front2> of your card.",
- "request_password_reset_to_reconfirm": "Request password reset email to reconfirm"
+ "request_password_reset_to_reconfirm": "Request password reset email to reconfirm",
+ "go_next_page": "Go to Next Page",
+ "go_prev_page": "Go to Previous Page",
+ "page_current": "Page __page__, Current Page",
+ "go_page": "Go to page __page__",
+ "pagination_navigation": "Pagination Navigation"
}
diff --git a/services/web/test/frontend/shared/components/pagination.test.js b/services/web/test/frontend/shared/components/pagination.test.js
new file mode 100644
index 0000000000..07ffb04dc1
--- /dev/null
+++ b/services/web/test/frontend/shared/components/pagination.test.js
@@ -0,0 +1,68 @@
+import { expect } from 'chai'
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import Pagination from '../../../../frontend/js/shared/components/pagination'
+
+describe('', function () {
+ it('renders with current page handled', async function () {
+ render(
+ {}} />
+ )
+ await screen.findByLabelText('Page 6, Current Page')
+ })
+ it('renders with nearby page buttons and prev/next button', async function () {
+ render(
+ {}} />
+ )
+ await screen.findByLabelText('Page 2, Current Page')
+ await screen.findByLabelText('Go to page 1')
+ await screen.findByLabelText('Go to page 3')
+ await screen.findByLabelText('Go to page 4')
+ await screen.findByLabelText('Go to Previous Page')
+ await screen.findByLabelText('Go to Next Page')
+ })
+ it('does not render the prev button when expected', async function () {
+ render(
+ {}} />
+ )
+ await screen.findByLabelText('Page 1, Current Page')
+ await screen.findByLabelText('Go to Next Page')
+ expect(screen.queryByLabelText('Go to Prev Page')).to.be.null
+ })
+ it('does not render the next button when expected', async function () {
+ render(
+ {}} />
+ )
+ await screen.findByLabelText('Page 2, Current Page')
+ await screen.findByLabelText('Go to Previous Page')
+ expect(screen.queryByLabelText('Go to Next Page')).to.be.null
+ })
+ it('renders 1 ellipses when there are more pages than buttons and on first page', async function () {
+ render(
+ {}} />
+ )
+ const ellipses = await screen.findAllByText('…')
+ expect(ellipses.length).to.equal(1)
+ })
+ it('renders 1 ellipses when on last page and there are more previous pages than buttons', async function () {
+ render(
+ {}} />
+ )
+ const ellipses = await screen.findAllByText('…')
+ expect(ellipses.length).to.equal(1)
+ })
+ it('renders 2 ellipses when there are more pages than buttons', async function () {
+ render(
+ {}} />
+ )
+ const ellipses = await screen.findAllByText('…')
+ expect(ellipses.length).to.equal(2)
+ })
+ it('only renders the number of page buttons set by maxOtherPageButtons', async function () {
+ render(
+ {}} />
+ )
+ const items = document.querySelectorAll('button')
+ expect(items.length).to.equal(6) // 5 page buttons + next button
+ })
+})