Last active
December 9, 2016 09:59
-
-
Save gryzzly/5550460952817414b948 to your computer and use it in GitHub Desktop.
React Pagination (similar to Github’s pagination)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict'; | |
import React, {PropTypes} from 'react'; | |
import classNames from 'classnames'; | |
function last(list) { | |
return list[list.length - 1]; | |
} | |
function areAdjacent(prev, curr) { | |
return last(prev).index + 1 === curr[0].index; | |
} | |
function isLeftClickEvent(event) { | |
return event.button === 0; | |
} | |
function isModifiedEvent(event) { | |
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); | |
} | |
function getQueryParamPattern(queryParam) { | |
// /(\&|\?)page=(\d)/ | |
return new RegExp('(\\&|\\?)' + queryParam + '=(?:\\d)'); | |
} | |
export default React.createClass({ | |
displayName: 'Pagination', | |
propTypes: { | |
paddingSize: PropTypes.number.isRequired, | |
total: PropTypes.number.isRequired, | |
pageSize: PropTypes.number.isRequired, | |
page: PropTypes.number.isRequired, | |
baseLink: PropTypes.string.isRequired, | |
queryParam: PropTypes.string.isRequired | |
}, | |
componentWillMount() { | |
this.queryParamPattern = getQueryParamPattern(this.props.queryParam); | |
}, | |
componentWillReceiveProps(nextProps) { | |
if (this.props.queryParam !== nextProps.queryParam) { | |
this.queryParamPattern = getQueryParamPattern(nextProps.queryParam); | |
} | |
}, | |
componentDidUpdate(prevProps) { | |
// update scroll position when changing pages in the paginated list | |
// | |
// React Router’s provides a way to trigger window.scrollTo(0, 0) via | |
// `scrollBehavior` option to `Router.create` but that fails because | |
// of the way CSS layout of the page is currently built – | |
// user scrolls a container div and not the document. | |
// | |
// We could provide custom `updateScrollPosition` function but Router | |
// doesn't call this function on query change (only route param changes). | |
// | |
// https://github.com/rackt/react-router/issues/690 | |
// https://github.com/rackt/react-router/issues/439 | |
if (prevProps.page !== this.props.page) { | |
document.querySelector('#app').scrollTop = 0; | |
} | |
}, | |
getDefaultProps() { | |
return { | |
// the padding around current item | |
// | | | |
// [1, 2, 3 … 8, 9, 10, … 98, 99, 100] | |
// | | | | | |
// edge padding edge padding | |
paddingSize: 3, | |
queryParam: 'page' | |
}; | |
}, | |
onPaginate(index, e) { | |
// links have hrefs and should be possible to open in new tab/window | |
// when pressing mousewheel, triple tap, cmd+click etc. | |
if (!isModifiedEvent(event) && isLeftClickEvent(event)) { | |
e.preventDefault(); | |
if (this.props.onPaginate) { | |
this.props.onPaginate(index); | |
} | |
} | |
}, | |
buildHref(i) { | |
return this.props.baseLink.replace(this.queryParamPattern, (match, separator) => { | |
return separator + this.props.queryParam + '=' + i; | |
}); | |
}, | |
buildPaginationLink(currentPage, i) { | |
const className = classNames('pagination-link pagination-page', { | |
current: currentPage === i | |
}); | |
return ( | |
<a className={className} href={this.buildHref(i)} key={i} onClick={this.onPaginate.bind(this, i)}>{i}</a> | |
); | |
}, | |
buildEllipsis(index) { | |
const className = 'pagination-link pagination-page pagination-ellipsis disabled'; | |
return (<a key={index} className={className}>…</a>); | |
}, | |
buildPageLinks() { | |
const padding = this.props.paddingSize; | |
const total = this.props.total; | |
const pageSize = this.props.pageSize; | |
const current = this.props.page; | |
const pages = Math.ceil(total / pageSize); | |
let left = []; | |
let middle = []; | |
let right = []; | |
const saveItem = (list, i) => { | |
list.push({ | |
link: this.buildPaginationLink(current, i), | |
// we save indices to be able to compare adjacency of the edges | |
index: i | |
}); | |
}; | |
for (var i = 1; i <= pages; i++) { | |
// first "padding" items | |
// [1, 2, 3] | |
if (i < padding + 1) { | |
saveItem(left, i); | |
continue; | |
} | |
// last "padding" items | |
// [98, 99, 100] | |
if (i > pages - padding) { | |
saveItem(right, i); | |
continue; | |
} | |
// items in between | |
if (i > current - (padding - 1) && i < current + (padding - 1)) { | |
saveItem(middle, i); | |
continue; | |
} | |
} | |
// find non-adjacent parts to insert the ellipsis and concat everything | |
return [left, middle, right] | |
.filter(list => list.length) | |
.reduce((prev, curr) => { | |
if (!curr.length) { | |
return prev; | |
} | |
if (!prev.length) { | |
return curr; | |
} | |
const nextPart = areAdjacent(prev, curr) | |
? [curr] | |
: [{link: this.buildEllipsis(last(prev).index + 1)}, curr]; | |
return prev.concat(...nextPart); | |
}, []) | |
// prepare array for React render (expose key’d React elements) | |
.map(item => item.link); | |
}, | |
render() { | |
const current = this.props.page; | |
const pages = Math.ceil(this.props.total / this.props.pageSize); | |
const prevClassName = classNames('pagination-link pagination-prev', { | |
disabled: current === 1 | |
}); | |
const nextClassName = classNames('pagination-link pagination-next', { | |
disabled: current === pages | |
}); | |
return ( | |
<div className="pagination"> | |
<a className={prevClassName} onClick={this.onPaginate.bind(this, current - 1)}>Prev</a> | |
{this.buildPageLinks()} | |
<a className={nextClassName} onClick={this.onPaginate.bind(this, current + 1)}>Next</a> | |
</div> | |
); | |
} | |
}); | |
'use strict'; | |
jest.dontMock('../Pagination'); | |
describe('Pagination', function() { | |
it('builds a list of links (current page in the middle)', function() { | |
var React = require('react/addons'); | |
var TestUtils = React.addons.TestUtils; | |
var Pagination = require('../Pagination'); | |
var onPaginate = jest.genMockFunction(); | |
var pagination = TestUtils.renderIntoDocument( | |
<Pagination total={100} pageSize={5} page={10} onPaginate={onPaginate}/> | |
// PREV [1, 2, 3, …, 9, 10, 11, …, 18, 19, 20] NEXT | |
// 0 1 2 3 4 5 6 7 8 9 10 11 12 | |
); | |
var paginationDOMNode = React.findDOMNode(pagination); | |
expect(paginationDOMNode.nodeName).toEqual('DIV'); | |
var links = paginationDOMNode.querySelectorAll('a'); | |
expect(links.length).toEqual(13); | |
expect(links[0].textContent).toEqual('Prev'); | |
expect(links[4].textContent).toEqual('…'); | |
expect(links[5].textContent).toEqual('9'); | |
expect(links[8].textContent).toEqual('…'); | |
expect(links[12].textContent).toEqual('Next'); | |
// click "previous" | |
TestUtils.Simulate.click(links[0]); | |
// clicked once | |
expect(onPaginate.mock.calls.length).toEqual(1); | |
// next page would be 9 | |
expect(onPaginate.mock.calls[0][0] === 9); | |
// click "next" | |
TestUtils.Simulate.click(links[12]); | |
// second click | |
expect(onPaginate.mock.calls.length).toEqual(2); | |
// next page after 10 is 11! | |
expect(onPaginate.mock.calls[1][0]).toEqual(11); | |
// click <19> | |
TestUtils.Simulate.click(links[10]); | |
// third click | |
expect(onPaginate.mock.calls.length).toEqual(3); | |
// next page would be 19 | |
expect(onPaginate.mock.calls[2][0]).toEqual(19); | |
}); | |
it('builds a list of links (current page in the start)', function() { | |
var React = require('react/addons'); | |
var TestUtils = React.addons.TestUtils; | |
var Pagination = require('../Pagination'); | |
var pagination = TestUtils.renderIntoDocument( | |
<Pagination total={100} pageSize={5} page={2} onPaginate=""/> | |
// PREV [1, 2, 3, …, 18, 19, 20] NEXT | |
// 0 1 2 3 4 5 6 7 8 | |
); | |
var paginationDOMNode = React.findDOMNode(pagination); | |
expect(paginationDOMNode.nodeName).toEqual('DIV'); | |
var links = paginationDOMNode.querySelectorAll('a'); | |
expect(links.length).toEqual(9); | |
expect(links[4].textContent).toEqual('…'); | |
expect(links[5].textContent).toEqual('18'); | |
expect(links[7].textContent).toEqual('20'); | |
expect(links[8].textContent).toEqual('Next'); | |
}); | |
it('builds a list of links (current page before last 3 items)', function() { | |
var React = require('react/addons'); | |
var TestUtils = React.addons.TestUtils; | |
var Pagination = require('../Pagination'); | |
var pagination = TestUtils.renderIntoDocument( | |
<Pagination total={100} pageSize={5} page={16} onPaginate=""/> | |
// PREV [1, 2, 3, …, 15, 16, 17, 18, 19, 20] NEXT | |
// 0 1 2 3 4 5 6 7 8 9 10 11 | |
); | |
var paginationDOMNode = React.findDOMNode(pagination); | |
expect(paginationDOMNode.nodeName).toEqual('DIV'); | |
var links = paginationDOMNode.querySelectorAll('a'); | |
expect(links.length).toEqual(12); | |
expect(links[3].textContent).toEqual('3'); | |
expect(links[4].textContent).toEqual('…'); | |
expect(links[5].textContent).toEqual('15'); | |
expect(links[6].textContent).toEqual('16'); | |
expect(links[7].textContent).toEqual('17'); | |
expect(links[8].textContent).toEqual('18'); | |
expect(links[10].textContent).toEqual('20'); | |
expect(links[11].textContent).toEqual('Next'); | |
}); | |
it('builds a list of links (current page right after first 3 items)', function() { | |
var React = require('react/addons'); | |
var TestUtils = React.addons.TestUtils; | |
var Pagination = require('../Pagination'); | |
var pagination = TestUtils.renderIntoDocument( | |
<Pagination total={100} pageSize={5} page={5} onPaginate=""/> | |
// PREV [1, 2, 3, 4, 5, 6, …, 18, 19, 20] NEXT | |
// 0 1 2 3 4 5 6 7 8 9 10 11 | |
); | |
var paginationDOMNode = React.findDOMNode(pagination); | |
expect(paginationDOMNode.nodeName).toEqual('DIV'); | |
var links = paginationDOMNode.querySelectorAll('a'); | |
expect(links.length).toEqual(12); | |
expect(links[3].textContent).toEqual('3'); | |
expect(links[4].textContent).toEqual('4'); | |
expect(links[7].textContent).toEqual('…'); | |
expect(links[11].textContent).toEqual('Next'); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment