mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Merge pull request #4202 from overleaf/jel-gallery-pagination
Add pagination to the gallery GitOrigin-RevId: 7107133da5e5ccf316235a6688070203c8bfa566
This commit is contained in:
parent
c34d5997e9
commit
f8ab7c32ea
6 changed files with 283 additions and 2 deletions
|
@ -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": "",
|
||||
|
|
170
services/web/frontend/js/shared/components/pagination.js
Normal file
170
services/web/frontend/js/shared/components/pagination.js
Normal file
|
@ -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 (
|
||||
<nav role="navigation" aria-label={t('pagination_navigation')}>
|
||||
<ul className="pagination">
|
||||
{currentPage > 1 && (
|
||||
<li>
|
||||
<button
|
||||
onClick={event => handlePageClick(event, currentPage - 1)}
|
||||
aria-label={t('go_prev_page')}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{morePrevPages && (
|
||||
<li>
|
||||
<span className="ellipses">…</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{pageButtons.map(page => (
|
||||
<PaginationItem
|
||||
key={`prev-page-${page}`}
|
||||
page={page}
|
||||
currentPage={currentPage}
|
||||
handlePageClick={handlePageClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{moreNextPages && (
|
||||
<li>
|
||||
<span className="ellipses">…</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{currentPage < totalPages && (
|
||||
<li>
|
||||
<button
|
||||
onClick={event => handlePageClick(event, currentPage + 1)}
|
||||
aria-label={t('go_next_page')}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className={itemClassName}>
|
||||
<button
|
||||
aria-current={ariaCurrent}
|
||||
onClick={event => handlePageClick(event, page)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
21
services/web/frontend/stories/pagination.stories.js
Normal file
21
services/web/frontend/stories/pagination.stories.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
|
||||
import Pagination from '../js/shared/components/pagination'
|
||||
|
||||
export const Interactive = args => {
|
||||
return <Pagination {...args} />
|
||||
}
|
||||
|
||||
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 } },
|
||||
},
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 Discover</0>, the <1>3 digits</1> on the <2>back</2> of your card.",
|
||||
"for_american_express": "For <0>American Express</0>, the <1>4 digits</1> on the <2>front</2> 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"
|
||||
}
|
||||
|
|
|
@ -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('<Pagination />', function () {
|
||||
it('renders with current page handled', async function () {
|
||||
render(
|
||||
<Pagination currentPage={6} totalPages={10} handlePageClick={() => {}} />
|
||||
)
|
||||
await screen.findByLabelText('Page 6, Current Page')
|
||||
})
|
||||
it('renders with nearby page buttons and prev/next button', async function () {
|
||||
render(
|
||||
<Pagination currentPage={2} totalPages={4} handlePageClick={() => {}} />
|
||||
)
|
||||
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(
|
||||
<Pagination currentPage={1} totalPages={2} handlePageClick={() => {}} />
|
||||
)
|
||||
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(
|
||||
<Pagination currentPage={2} totalPages={2} handlePageClick={() => {}} />
|
||||
)
|
||||
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(
|
||||
<Pagination currentPage={1} totalPages={10} handlePageClick={() => {}} />
|
||||
)
|
||||
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(
|
||||
<Pagination currentPage={10} totalPages={10} handlePageClick={() => {}} />
|
||||
)
|
||||
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(
|
||||
<Pagination currentPage={5} totalPages={10} handlePageClick={() => {}} />
|
||||
)
|
||||
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(
|
||||
<Pagination currentPage={1} totalPages={100} handlePageClick={() => {}} />
|
||||
)
|
||||
const items = document.querySelectorAll('button')
|
||||
expect(items.length).to.equal(6) // 5 page buttons + next button
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue